scaffold.mjs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. #!/usr/bin/env node
  2. /**
  3. * scaffold.mjs —— 生成 Lit 原生 ESM 无构建项目骨架。
  4. *
  5. * 用法:
  6. * node scaffold.mjs <targetDir> [--project <name>] [--force]
  7. *
  8. * 原则:已存在的文件默认不覆盖;不引入任何构建工具;所有代码无装饰器。
  9. */
  10. import { mkdirSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs';
  11. import { join, resolve, basename } from 'node:path';
  12. const args = process.argv.slice(2);
  13. const target = resolve(args.find((a) => !a.startsWith('--')) || '.');
  14. const force = args.includes('--force');
  15. const projIdx = args.indexOf('--project');
  16. const project = projIdx > -1 ? args[projIdx + 1] : basename(target);
  17. if (!project || !/^[a-z0-9][a-z0-9-]*$/.test(project)) {
  18. console.error(`✗ 项目名非法:${project}(须为小写 kebab-case)`);
  19. process.exit(2);
  20. }
  21. let created = 0, skipped = 0;
  22. function write(rel, content) {
  23. const p = join(target, rel);
  24. if (existsSync(p) && !force) { skipped++; console.log(` skip ${rel}(已存在)`); return; }
  25. mkdirSync(join(p, '..'), { recursive: true });
  26. writeFileSync(p, content);
  27. created++; console.log(` write ${rel}`);
  28. }
  29. const DEP_VERSIONS = {
  30. lit: '3.3.3', 'lit-html': '3.3.3', 'lit-element': '4.2.2', '@lit/reactive-element': '2.1.2',
  31. '@lit/context': '1.1.6', '@lit-labs/signals': '0.3.0', 'signal-polyfill': '0.2.2',
  32. };
  33. console.log(`\n生成 Lit 无构建项目:${target}\n项目名:${project}\n`);
  34. // 目录
  35. for (const d of ['src/components', 'src/services', 'src/utils', 'src/routes']) {
  36. mkdirSync(join(target, d), { recursive: true });
  37. }
  38. // index.html
  39. write('src/index.html', `<!doctype html>
  40. <html lang="zh-CN">
  41. <head>
  42. <meta charset="utf-8" />
  43. <meta name="viewport" content="width=device-width, initial-scale=1" />
  44. <title>${project}</title>
  45. <!-- 二级子路径部署必须;根部署可改 "/" -->
  46. <base href="/${project}/" />
  47. <!-- 生产:指向 FMODE Repose 公共 importmap -->
  48. <script type="importmap" src="/npm/importmap/lit-v3.json"></script>
  49. <!-- 本地无 Repose 时改用: <script type="importmap" src="./importmap.json"></script> -->
  50. <style>:root{--brand:#2b6cb0;--bg:#f7fafc;--border:#e2e8f0;font-family:system-ui,sans-serif}body{margin:0}</style>
  51. </head>
  52. <body>
  53. <app-root></app-root>
  54. <script type="module" src="./app.js"></script>
  55. </body>
  56. </html>
  57. `);
  58. // importmap.json(本地开发用)
  59. write('src/importmap.json', JSON.stringify({
  60. _comment: '本地开发用;生产由 FMODE Repose 的 /npm/importmap/lit-v3.json 提供。',
  61. imports: {
  62. lit: `/npm/lit/v${DEP_VERSIONS.lit}/lit.js`,
  63. 'lit/': `/npm/lit/v${DEP_VERSIONS.lit}/`,
  64. 'lit-html': `/npm/lit-html/v${DEP_VERSIONS['lit-html']}/lit-html.js`,
  65. 'lit-element': `/npm/lit-element/v${DEP_VERSIONS['lit-element']}/lit-element.js`,
  66. '@lit/reactive-element': `/npm/@lit/reactive-element/v${DEP_VERSIONS['@lit/reactive-element']}/reactive-element.js`,
  67. '@lit/context': `/npm/@lit/context/v${DEP_VERSIONS['@lit/context']}/index.js`,
  68. '@lit-labs/signals': `/npm/@lit-labs/signals/v${DEP_VERSIONS['@lit-labs/signals']}/index.js`,
  69. 'signal-polyfill': `/npm/signal-polyfill/v${DEP_VERSIONS['signal-polyfill']}/dist/index.js`,
  70. },
  71. }, null, 2) + '\n');
  72. // app.js
  73. write('src/app.js', `/**
  74. * app.js —— 应用装配:provide 全局服务 + 挂载路由 + 根组件。不含业务逻辑。
  75. */
  76. import { LitElement, html, css } from 'lit';
  77. import { provide } from '@lit/context';
  78. import { authContext } from './services/contexts.js';
  79. import { authService } from './services/auth-service.js';
  80. export class AppRoot extends LitElement {
  81. static styles = css\`:host{display:block;background:var(--bg)}\`;
  82. render() {
  83. return html\`
  84. \${provide(authContext, authService)}
  85. <app-shell></app-shell>
  86. \`;
  87. }
  88. }
  89. customElements.define('app-root', AppRoot);
  90. `);
  91. // services/contexts.js
  92. write('src/services/contexts.js', `/**
  93. * 注入令牌集中定义(令牌必须同一模块实例)。
  94. */
  95. import { createContext } from '@lit/context';
  96. export const authContext = createContext('${project}.auth');
  97. `);
  98. // services/auth-service.js
  99. write('src/services/auth-service.js', `/**
  100. * 全局登录态(Signal Service)。纯 ESM Class,无 DOM 依赖,可单测。
  101. */
  102. import { signal, computed } from '@lit-labs/signals';
  103. class AuthService {
  104. #user = signal(null);
  105. #token = signal(localStorage.getItem('token') || '');
  106. get user() { return this.#user; }
  107. get isLoggedIn() { return computed(() => this.#user.get() != null); }
  108. getToken() { return this.#token.get(); }
  109. async login(username, password) {
  110. // TODO: 接真实接口
  111. // const { token, user } = await http.post('/auth/login', { username, password });
  112. const token = 'demo-token', user = { name: username };
  113. this.#token.set(token); this.#user.set(user);
  114. localStorage.setItem('token', token);
  115. }
  116. logout() { this.#token.set(''); this.#user.set(null); localStorage.removeItem('token'); }
  117. }
  118. export const authService = new AuthService();
  119. `);
  120. // utils/http.js
  121. write('src/utils/http.js', `/**
  122. * 原生 fetch 薄封装。token 通过 configureHttp 注入,避免 utils 反向依赖 services。
  123. */
  124. let getToken = () => '';
  125. let onUnauthorized = () => {};
  126. export function configureHttp({ token, unauthorized } = {}) {
  127. if (token) getToken = token;
  128. if (unauthorized) onUnauthorized = unauthorized;
  129. }
  130. export class HttpError extends Error {
  131. constructor(status, body) { super('HTTP ' + status); this.status = status; this.body = body; }
  132. }
  133. const BASE = '/api';
  134. async function request(method, path, { body, headers = {}, query } = {}) {
  135. let url = BASE + path;
  136. if (query) url += '?' + new URLSearchParams(query).toString();
  137. const h = { 'Content-Type': 'application/json', ...headers };
  138. const t = getToken(); if (t) h.Authorization = 'Bearer ' + t;
  139. const res = await fetch(url, { method, headers: h, body: body != null ? JSON.stringify(body) : undefined, credentials: 'same-origin' });
  140. if (res.status === 401) { onUnauthorized(); throw new HttpError(401, null); }
  141. const ct = res.headers.get('content-type') || '';
  142. const data = ct.includes('application/json') ? await res.json() : await res.text();
  143. if (!res.ok) throw new HttpError(res.status, data);
  144. return data;
  145. }
  146. export const http = {
  147. get: (p, o) => request('GET', p, o),
  148. post: (p, body, o) => request('POST', p, { ...o, body }),
  149. put: (p, body, o) => request('PUT', p, { ...o, body }),
  150. del: (p, o) => request('DELETE', p, o),
  151. };
  152. `);
  153. // utils/router.js —— 子路径部署安全(不要用 new URL(path, baseURI),会丢部署前缀)
  154. write('src/utils/router.js', `/**
  155. * utils/router.js —— 子路径部署安全的导航(只用 History API,干净 URL)。
  156. *
  157. * ⚠️ 不要写 new URL(path, document.baseURI) 做导航:path 以 '/' 开头时,
  158. * 相对解析会丢掉 base 的路径前缀(/proj/users -> /users),子路径部署刷新即 404。
  159. * 正确做法:把应用内路径拼到 base 的 pathname 上再解析。
  160. * 详见规则仓 .claude/rules/routing-best-practices.md
  161. */
  162. export function basePath() {
  163. return new URL(document.baseURI).pathname.replace(/\\/+$/, '');
  164. }
  165. export function resolveUrl(path) {
  166. const base = basePath();
  167. const q = path.indexOf('?'), h = path.indexOf('#');
  168. let cut = path.length;
  169. if (q !== -1) cut = Math.min(cut, q);
  170. if (h !== -1) cut = Math.min(cut, h);
  171. let p = path.slice(0, cut);
  172. const rest = path.slice(cut); // 查询串 / hash 原样保留
  173. if (!p.startsWith('/')) p = '/' + p;
  174. return new URL(base + p + rest, location.origin);
  175. }
  176. export function navigate(path, { replace = false } = {}) {
  177. const url = resolveUrl(path);
  178. if (replace) history.replaceState({}, '', url); else history.pushState({}, '', url);
  179. window.dispatchEvent(new PopStateEvent('popstate')); // History 原生不触发
  180. }
  181. export function currentPath() {
  182. const base = basePath();
  183. let path = location.pathname;
  184. if (base && path.startsWith(base)) path = path.slice(base.length);
  185. if (!path.startsWith('/')) path = '/' + path;
  186. return path.replace(/\\/{2,}/g, '/') || '/'; // 折叠 '//'
  187. }
  188. export function currentQuery() {
  189. return Object.fromEntries(new URLSearchParams(location.search));
  190. }
  191. export function href(path) { return resolveUrl(path); } // 供真实 <a href>
  192. `);
  193. // components/app-shell.js —— 不用 @lit-labs/router(子路径部署会污染路由状态)
  194. write('src/components/app-shell.js', `/**
  195. * 布局壳:侧边菜单 + 内容出口。多标签版本见规则仓 tabs-keepalive.md。
  196. *
  197. * ⚠️ 不用 @lit-labs/router:它装全局 <a> 拦截器且用未剥前缀的 location.pathname
  198. * 匹配路由,子路径部署下会污染路由状态。改为订阅 popstate + currentPath()。
  199. */
  200. import { LitElement, html, css } from 'lit';
  201. import { currentPath, navigate } from '../utils/router.js';
  202. import './page-home.js';
  203. const ROUTES = [{ path: '/', tag: 'page-home', title: '首页' }];
  204. export class AppShell extends LitElement {
  205. static styles = css\`:host{display:block;padding:16px}\`;
  206. static properties = { _path: { state: true } };
  207. #onPop = () => { this._path = currentPath(); };
  208. connectedCallback() {
  209. super.connectedCallback();
  210. this._path = currentPath();
  211. window.addEventListener('popstate', this.#onPop);
  212. }
  213. disconnectedCallback() {
  214. window.removeEventListener('popstate', this.#onPop);
  215. super.disconnectedCallback();
  216. }
  217. render() {
  218. const hit = ROUTES.find((r) => r.path === this._path) || ROUTES[ROUTES.length - 1];
  219. return html\`
  220. <nav>
  221. \${ROUTES.map((r) => html\`<button @click=\${() => navigate(r.path)}>\${r.title}</button>\`)}
  222. </nav>
  223. <main>\${this.#view(hit)}</main>
  224. \`;
  225. }
  226. #view(r) { return document.createElement(r.tag); }
  227. }
  228. customElements.define('app-shell', AppShell);
  229. `);
  230. // components/page-home.js
  231. write('src/components/page-home.js', `/**
  232. * 首页示例组件。无装饰器;用 static properties。
  233. */
  234. import { LitElement, html, css } from 'lit';
  235. import { SignalWatcher } from '@lit-labs/signals';
  236. import { ContextConsumer } from '@lit/context';
  237. import { authContext } from '../services/contexts.js';
  238. export class PageHome extends SignalWatcher(LitElement) {
  239. static properties = { _auth: { state: true } };
  240. #consumer = new ContextConsumer(this, { context: authContext, subscribe: true, callback: (v) => { this._auth = v; } });
  241. static styles = css\`h1{color:var(--brand)}\`;
  242. render() {
  243. const name = this._auth?.user.get()?.name;
  244. return html\`<h1>${project}: \${name || 'hello lit'}</h1>\`;
  245. }
  246. }
  247. customElements.define('page-home', PageHome);
  248. `);
  249. // package.json
  250. write('package.json', JSON.stringify({
  251. name: project, private: true, type: 'module',
  252. _comment: 'devDependencies 仅用于 IDE 类型提示,不参与部署产物。',
  253. scripts: { serve: 'npx --yes serve src', deploy: 'bash scripts/deploy.sh ' + project },
  254. devDependencies: {
  255. lit: '^' + DEP_VERSIONS.lit, '@lit/context': '^' + DEP_VERSIONS['@lit/context'],
  256. '@lit-labs/signals': '^' + DEP_VERSIONS['@lit-labs/signals'],
  257. },
  258. }, null, 2) + '\n');
  259. // scripts/deploy.sh
  260. write('scripts/deploy.sh', `#!/usr/bin/env bash
  261. # 无构建部署:同步 src/ 到 OBS + CDN 刷新。凭据由环境/既有脚本提供,不写入仓库。
  262. set -euo pipefail
  263. PROJECT="\${1:-${project}}"
  264. BUCKET="\${OBS_BUCKET:?需设置 OBS_BUCKET}"
  265. EP="\${OBS_ENDPOINT:-obs.<region>.myhuaweicloud.com}"
  266. OBS="\${OBSUTIL:-obsutil}"
  267. SRC="$(cd "$(dirname "$0")/.." && pwd)/src"
  268. "$OBS" rm "$BUCKET/$PROJECT/index.html" -e="$EP" -f >/dev/null 2>&1 || true
  269. "$OBS" cp "$SRC/index.html" "$BUCKET/$PROJECT/index.html" -e="$EP" -acl=public-read -f \\
  270. -meta="Cache-Control:no-cache#Content-Type:text/html; charset=utf-8"
  271. "$OBS" sync "$SRC/" "$BUCKET/$PROJECT/" -e="$EP" -acl=public-read -f
  272. echo "完成。确认 CDN 回退:不存在路径 200 返回 index.html(禁 302)。"
  273. `);
  274. // .gitignore
  275. write('.gitignore', 'node_modules/\ndist/\n.DS_Store\n');
  276. console.log(`\n✓ 完成:新建 ${created} 个文件,跳过 ${skipped} 个。`);
  277. console.log(`\n下一步:`);
  278. console.log(` 1. 本地预览: cd ${target} && npx serve src`);
  279. console.log(` 2. 或在 index.html 指向 Repose 公共 importmap 后直接部署`);
  280. console.log(` 3. 规则细则见 fmode-code-lit 规则仓 .claude/rules/\n`);