new.mjs 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. #!/usr/bin/env node
  2. /**
  3. * new.mjs —— 生成单个 Lit 无构建单元(组件 / 服务 / 页面 / 核心库 / 组件库)。
  4. *
  5. * 用法:
  6. * node new.mjs component user-card [--dir src/components]
  7. * node new.mjs service auth-service [--dir src/services]
  8. * node new.mjs page page-order-list [--dir src/routes]
  9. * node new.mjs core pricing [--dir lib/core] # 纯 ESM 逻辑模块(零 DOM)
  10. * node new.mjs wc quote-calc [--dir lib/wc] # Web Component(跨框架组件库)
  11. *
  12. * 生成物一律无装饰器,遵循规则仓命名与分层规范。
  13. */
  14. import { mkdirSync, writeFileSync, existsSync } from 'node:fs';
  15. import { join, resolve } from 'node:path';
  16. const args = process.argv.slice(2);
  17. const kind = args[0];
  18. const name = args[1];
  19. const dirIdx = args.indexOf('--dir');
  20. const force = args.includes('--force');
  21. const KINDS = {
  22. component: { dir: 'src/components', file: (n) => n + '.js' },
  23. service: { dir: 'src/services', file: (n) => n + '.js' }, // 文件名已含 -service 后缀
  24. page: { dir: 'src/routes', file: (n) => n + '.js' },
  25. core: { dir: 'lib/core', file: (n) => n + '.js' }, // 纯 ESM 核心库(零浏览器依赖)
  26. wc: { dir: 'lib/wc', file: (n) => n + '.js' }, // Web Component 组件库
  27. };
  28. if (!kind || !KINDS[kind] || !name) {
  29. console.error(`用法:node new.mjs <component|service|page|core|wc> <name> [--dir <dir>] [--force]`);
  30. process.exit(2);
  31. }
  32. if (!/^[a-z][a-z0-9-]*$/.test(name)) {
  33. console.error(`✗ 名称须为小写 kebab-case:${name}`);
  34. process.exit(2);
  35. }
  36. const spec = KINDS[kind];
  37. const dir = dirIdx > -1 ? resolve(args[dirIdx + 1]) : resolve(spec.dir);
  38. // service:允许传 order 或 order-service,统一补一个 -service 后缀(避免 order-service-service)
  39. const baseName = kind === 'service' ? name.replace(/-service$/, '') : name;
  40. const fileBase = kind === 'service' ? baseName + '-service' : name;
  41. const pascal = fileBase.split('-').map((s) => s[0].toUpperCase() + s.slice(1)).join('');
  42. // 标签必须含连字符:page- 前缀已保证;component 若单段则补前缀
  43. const tag = (kind === 'service' || kind === 'core') ? null : (fileBase.includes('-') ? fileBase : 'x-' + fileBase);
  44. const path = join(dir, spec.file(fileBase));
  45. const relToUtils = kind === 'page' ? '../../utils' : '../utils';
  46. if (existsSync(path) && !force) {
  47. console.error(`✗ 已存在:${path}(用 --force 覆盖)`);
  48. process.exit(1);
  49. }
  50. mkdirSync(dir, { recursive: true });
  51. let content = '';
  52. if (kind === 'component') {
  53. content = `/**
  54. * ${name} —— 通用组件。
  55. * 无装饰器:static properties + customElements.define。
  56. */
  57. import { LitElement, html, css } from 'lit';
  58. export class ${pascal} extends LitElement {
  59. static properties = {
  60. label: { type: String },
  61. };
  62. static styles = css\`
  63. :host { display: block; }
  64. \`;
  65. constructor() { super(); this.label = ''; }
  66. render() {
  67. return html\`<div>\${this.label}</div>\`;
  68. }
  69. }
  70. customElements.define('${tag}', ${pascal});
  71. `;
  72. } else if (kind === 'service') {
  73. const endpoint = baseName;
  74. const singleton = pascal.charAt(0).toLowerCase() + pascal.slice(1);
  75. content = `/**
  76. * ${pascal} —— 业务服务(Signal + 单例)。无 DOM 依赖,可单测。
  77. */
  78. import { signal, computed } from '@lit-labs/signals';
  79. // import { http } from '${relToUtils}/http.js';
  80. class ${pascal} {
  81. #items = signal([]);
  82. get items() { return this.#items; }
  83. get count() { return computed(() => this.#items.get().length); }
  84. async fetchAll() {
  85. // const data = await http.get('/${endpoint}');
  86. // this.#items.set(data.items ?? []);
  87. return this.#items.get();
  88. }
  89. set(items) { this.#items.set(items); }
  90. }
  91. export const ${singleton} = new ${pascal}();
  92. `;
  93. } else if (kind === 'core') {
  94. content = `/**
  95. * ${pascal} —— 核心库模块(core):纯 ESM、零浏览器依赖。
  96. *
  97. * 铁律:不碰 window/document/localStorage/fetch,不 import lit。
  98. * 不确定输入(时间/id/网络)一律从参数注入 → Node 可确定化单测。
  99. * 规则:见 .claude/rules/core-library.md
  100. */
  101. /**
  102. * @param {any[]} items
  103. * @param {{ now?: () => number, id?: () => string }} [opts]
  104. */
  105. export function ${pascal.charAt(0).toLowerCase() + pascal.slice(1)}(items, opts = {}) {
  106. const { now = () => Date.now(), id = () => Math.random().toString(36).slice(2, 10) } = opts;
  107. // TODO: 纯逻辑(校验 / 计算 / 状态机),全部由入参决定
  108. return { id: id(), at: now(), count: Array.isArray(items) ? items.length : 0 };
  109. }
  110. `;
  111. } else if (kind === 'wc') {
  112. content = `/**
  113. * ${pascal} —— 组件库(Web Component):仅浏览器交互,逻辑来自 core。
  114. *
  115. * ⚠️ 必须用 Lit 框架特性实现(extends LitElement + html + css),禁止手写原生 wc
  116. * (extends HTMLElement / attachShadow() / 手写 observedAttributes)。
  117. * Lit 本身基于 Web Component 标准,产物仍是跨框架可用的自定义元素。
  118. *
  119. * 契约:语义属性 + composed 事件 + slot + CSS 变量 + ::part
  120. * 无装饰器:static properties + customElements.define(判重)
  121. * 规则:见 .claude/rules/web-component-library.md
  122. */
  123. import { LitElement, html, css } from 'lit';
  124. export class ${pascal} extends LitElement {
  125. static properties = {
  126. label: { type: String },
  127. value: { type: Object },
  128. disabled: { type: Boolean, reflect: true },
  129. _state: { state: true },
  130. };
  131. static styles = css\`
  132. :host { display: block; --x-accent: #2b6cb0; }
  133. button { background: var(--x-accent); color: #fff; border: 0; border-radius: 6px; padding: 6px 12px; }
  134. \`;
  135. /** 宿主注入 I/O(组件库不自己 fetch) */
  136. #transport = null;
  137. set transport(t) { this.#transport = t; }
  138. get transport() { return this.#transport; }
  139. constructor() {
  140. super();
  141. this.label = '';
  142. this.value = null;
  143. this.disabled = false;
  144. this._state = 'idle';
  145. }
  146. render() {
  147. return html\`
  148. <button part="button" ?disabled=\${this.disabled || this._state !== 'idle'}
  149. @click=\${this.#onClick}>
  150. <slot name="label">\${this.label}</slot>
  151. </button>
  152. \`;
  153. }
  154. async #onClick() {
  155. this._state = 'busy';
  156. try {
  157. // 逻辑应由 core 提供;这里仅演示接线
  158. const result = this.#transport ? await this.#transport.call(this.value) : this.value;
  159. this.dispatchEvent(new CustomEvent('${tag}-done', {
  160. detail: { result }, bubbles: true, composed: true,
  161. }));
  162. } catch (err) {
  163. this.dispatchEvent(new CustomEvent('${tag}-error', {
  164. detail: { code: err.code, message: err.message }, bubbles: true, composed: true,
  165. }));
  166. } finally {
  167. this._state = 'idle';
  168. }
  169. }
  170. }
  171. if (!customElements.get('${tag}')) customElements.define('${tag}', ${pascal});
  172. `;
  173. } else {
  174. content = `/**
  175. * ${name} —— 页面组件(路由挂载)。
  176. */
  177. import { LitElement, html, css } from 'lit';
  178. import { SignalWatcher } from '@lit-labs/signals';
  179. // import { navigate } from '${relToUtils}/router.js';
  180. export class ${pascal} extends SignalWatcher(LitElement) {
  181. static properties = {};
  182. static styles = css\`
  183. :host { display: block; }
  184. \`;
  185. render() {
  186. return html\`<h2>${pascal}</h2>\`;
  187. }
  188. }
  189. customElements.define('${tag}', ${pascal});
  190. `;
  191. }
  192. writeFileSync(path, content);
  193. console.log(`✓ 已生成 ${path}`);