@twobirds/microcomponents 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -46,6 +46,21 @@ An MCP (Model Context Protocol) server ships with the package so AI assistants c
46
46
  microcomponents-mcp # stdio server, straight from the installed package
47
47
  ```
48
48
 
49
+ `mc create` writes an `opencode.jsonc` into every scaffolded project, so opencode picks the server up as soon as you open the project — no config edit needed. opencode reads the project config from the repo root, and the server is started through `npx`, which resolves the version the project installed:
50
+
51
+ ```jsonc
52
+ {
53
+ "$schema": "https://opencode.ai/config.json",
54
+ "mcp": {
55
+ "microcomponents": {
56
+ "type": "local",
57
+ "command": ["npx", "-p", "@twobirds/microcomponents", "microcomponents-mcp"],
58
+ "enabled": true,
59
+ },
60
+ },
61
+ }
62
+ ```
63
+
49
64
  In a published package the server runs from `dist/mcp` and reads the prebuilt `api.json` and the concept essays that the build step emits next to it. During development it generates the index live from `src/` at startup, so it cannot drift away from the library it documents:
50
65
 
51
66
  ```bash
@@ -60,6 +75,20 @@ There is no hand-written API list in the server. The whole index is extracted fr
60
75
 
61
76
  `get-source-file` reads package-root relative paths (`dist/core/MC.d.ts`, `dist/mcp/concepts/event-system.md`) and refuses any path outside the package sources.
62
77
 
78
+ ## Vite Plugin
79
+
80
+ The package ships a Vite plugin as its own entry, `@twobirds/microcomponents/vite`, for the bundled production build:
81
+
82
+ ```ts
83
+ import { loadAssetsPlugin } from '@twobirds/microcomponents/vite';
84
+
85
+ export default defineConfig({
86
+ plugins: [loadAssetsPlugin(sourceRoot)],
87
+ });
88
+ ```
89
+
90
+ `loadAssetsPlugin` reads the companion `.css` / `.html` files of every DC class that declares `static assets = [...]` and embeds them as a `static _assets` property inside the class, so `loadAssets()` never fetches them at runtime. `mc create` wires it into the generated `vite.config.ts`, which bundles a single `bundle.js` into `public/`.
91
+
63
92
  ## CLI
64
93
 
65
94
  A small command-line tool scaffolds new components so the tag-to-file convention is never hand-written:
@@ -80,20 +109,25 @@ pnpm run cli -- new my-card --target-html --target-css --dry-run
80
109
  # would create src/my/card.target.css
81
110
  ```
82
111
 
83
- `mc create` scaffolds a complete dev environment for a namespace — a Vite dev server serving `src/` straight, a page shell that turns on `autoload(true)`, and a starter component with companion assets:
112
+ `mc create` scaffolds a complete dev environment for a namespace, following the three-path chain the library's own demos use — `src/` → (tsc) → `build/` → (bundle) → `public/`:
84
113
 
85
114
  ```bash
86
115
  pnpm run cli -- create --namespace mc --dir mc-app
87
116
  # created mc-app/package.json
117
+ # created mc-app/opencode.jsonc
88
118
  # created mc-app/src/index.html
119
+ # created mc-app/src/index-dev.html
89
120
  # created mc-app/src/mc/app.ts
90
121
  # created mc-app/src/mc/app.target.html
91
122
  # created mc-app/src/mc/app.target.css
123
+ # created mc-app/src/mc/bundle.ts
92
124
  # ...
93
125
  # project "mc" created in mc-app/ — run: cd mc-app && pnpm install && pnpm dev
94
126
  ```
95
127
 
96
- The namespace (1 to 4 lowercase letters) becomes the `src/<namespace>/` folder and the first segment of every component tag, so `mc` is the home of tags like `<mc-app>`. Because the page shell is the autoload base, `import.meta.url` + the tag-to-path rule resolves `<mc-app>` to `src/mc/app.js` — the compiled starter. `--dir` defaults to the namespace, `--dry-run` prints the plan without touching disk.
128
+ `pnpm dev` runs `tsc -w` and serves `build/`, so a saved component reaches the browser through `autoload()` with nothing bundled; companion assets are mirrored into `build/` next to the compiled component, which is where the autoloader looks for them. `pnpm build` emits one minified `bundle.js` into `public/` with every companion asset inlined by `loadAssetsPlugin`, the concatenated `bundle.css`, and the production shell — a single script tag, no autoloader. `opencode.jsonc` registers the MCP server, so opencode picks it up when you open the project.
129
+
130
+ The namespace (1 to 4 lowercase letters) becomes the `src/<namespace>/` folder and the first segment of every component tag, so `mc` is the home of tags like `<mc-app>`. Because the dev shell is the autoload base, `import.meta.url` + the tag-to-path rule resolves `<mc-app>` to `build/mc/app.js` — the compiled starter. New components are served automatically by the autoloader, but the bundle walks one entry, so add each one to `src/<namespace>/bundle.ts`. `--dir` defaults to the namespace, `--dry-run` prints the plan without touching disk.
97
131
 
98
132
  The project is written into a folder of its own, so its files are reported under that folder (`mc-app/src/mc/app.ts`) — the path you can find from the directory you ran the command in, not relative to the project root.
99
133
 
@@ -9,11 +9,11 @@ where a component lives.
9
9
 
10
10
  The namespace is the first segment of every component tag, so `xxx` makes `src/xxx/`
11
11
  the home of tags like `<xxx-app>` (tagPath `xxx-app` → `xxx/app`). The generated
12
- environment mirrors the repo's own demos: a Vite dev server serving `src/` straight,
13
- a page shell at that root that turns on `autoload(true)`, and a starter component with
14
- companion `targetHtml` / `targetCss` assets. Because the page shell is the autoload
15
- base, `import.meta.url` + `tagPath('xxx-app')` resolves to `src/xxx/app.js` — the
16
- compiled starter.
12
+ environment follows the three-path chain the library's own demos use: `src/` holds the
13
+ authored sources, `build/` receives the per-file `tsc` output that the dev server
14
+ serves through `autoload()`, and `public/` receives the bundled production site. Because
15
+ the dev shell is the autoload base, `import.meta.url` + `tagPath('xxx-app')` resolves to
16
+ `build/xxx/app.js` — the compiled starter.
17
17
 
18
18
  @module CLI/Project
19
19
  */
@@ -1 +1 @@
1
- {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/cli/create.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;EAkBE;AAUF;;;;;;;;;;EAUE;AACF,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,yCAAyC;AACzC,MAAM,WAAW,aAAa;IAC7B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;EAYE;AACF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CAuBhE"}
1
+ {"version":3,"file":"create.d.ts","sourceRoot":"","sources":["../../src/cli/create.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;EAkBE;AAUF;;;;;;;;;;EAUE;AACF,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAEpD;AAED,yCAAyC;AACzC,MAAM,WAAW,aAAa;IAC7B,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9B,+BAA+B;IAC/B,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;;;;;EAYE;AACF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,aAAa,CA0BhE"}
@@ -9,11 +9,11 @@ where a component lives.
9
9
 
10
10
  The namespace is the first segment of every component tag, so `xxx` makes `src/xxx/`
11
11
  the home of tags like `<xxx-app>` (tagPath `xxx-app` → `xxx/app`). The generated
12
- environment mirrors the repo's own demos: a Vite dev server serving `src/` straight,
13
- a page shell at that root that turns on `autoload(true)`, and a starter component with
14
- companion `targetHtml` / `targetCss` assets. Because the page shell is the autoload
15
- base, `import.meta.url` + `tagPath('xxx-app')` resolves to `src/xxx/app.js` — the
16
- compiled starter.
12
+ environment follows the three-path chain the library's own demos use: `src/` holds the
13
+ authored sources, `build/` receives the per-file `tsc` output that the dev server
14
+ serves through `autoload()`, and `public/` receives the bundled production site. Because
15
+ the dev shell is the autoload base, `import.meta.url` + `tagPath('xxx-app')` resolves to
16
+ `build/xxx/app.js` — the compiled starter.
17
17
 
18
18
  @module CLI/Project
19
19
  */
@@ -62,11 +62,14 @@ export function scaffoldProject(namespace) {
62
62
  files['tsconfig.vc.json'] = tsconfigVc();
63
63
  files['vite.config.ts'] = viteConfig(namespace);
64
64
  files['.gitignore'] = gitignore();
65
+ files['opencode.jsonc'] = opencodeConfig();
65
66
  files['README.md'] = readme(namespace, tag);
66
- files['src/index.html'] = shell(namespace, tag);
67
+ files['src/index.html'] = prodShell(tag);
68
+ files['src/index-dev.html'] = devShell(namespace, tag);
67
69
  files[`src/${base}.ts`] = appSource(namespace, tag);
68
70
  files[`src/${base}.target.html`] = appHtml(namespace);
69
71
  files[`src/${base}.target.css`] = appCss(tag);
72
+ files[`src/${namespace}/bundle.ts`] = bundle();
70
73
  return { files, namespace };
71
74
  }
72
75
  function packageJson(namespace) {
@@ -77,11 +80,11 @@ function packageJson(namespace) {
77
80
  scripts: {
78
81
  dev: 'vite',
79
82
  build: 'vite build',
80
- preview: 'vite preview',
83
+ preview: 'vite preview --outDir public --port 5174',
81
84
  typecheck: 'tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.vc.json',
82
85
  },
83
86
  dependencies: {
84
- '@twobirds/microcomponents': '^0.6.0',
87
+ '@twobirds/microcomponents': '^0.8.0',
85
88
  },
86
89
  devDependencies: {
87
90
  vite: '^8.1.5',
@@ -100,7 +103,9 @@ function tsconfig() {
100
103
  strict: true,
101
104
  esModuleInterop: true,
102
105
  skipLibCheck: true,
103
- noEmit: true,
106
+ rootDir: 'src',
107
+ outDir: 'build',
108
+ removeComments: true,
104
109
  },
105
110
  include: ['src/**/*.ts'],
106
111
  }, null, '\t');
@@ -123,9 +128,22 @@ function tsconfigVc() {
123
128
  }
124
129
  function gitignore() {
125
130
  return `node_modules/
126
- dist/
131
+ build/
132
+ public/
127
133
  `;
128
134
  }
135
+ function opencodeConfig() {
136
+ return JSON.stringify({
137
+ $schema: 'https://opencode.ai/config.json',
138
+ mcp: {
139
+ microcomponents: {
140
+ type: 'local',
141
+ command: ['npx', '-p', '@twobirds/microcomponents', 'microcomponents-mcp'],
142
+ enabled: true,
143
+ },
144
+ },
145
+ }, null, '\t');
146
+ }
129
147
  function readme(namespace, tag) {
130
148
  return `# ${namespace} App
131
149
 
@@ -143,14 +161,24 @@ pnpm install
143
161
  pnpm dev
144
162
  \`\`\`
145
163
 
164
+ The dev server runs \`tsc -w\` and serves \`build/\`, so a saved component reaches the
165
+ browser through \`autoload()\` with no bundling. Companion \`.css\`, \`.html\` and \`.json\`
166
+ files are mirrored into \`build/\` next to the compiled component, which is where the
167
+ autoloader looks for them.
168
+
146
169
  ## Build
147
170
 
148
171
  \`\`\`sh
149
172
  pnpm build
150
173
  \`\`\`
151
174
 
152
- Components compile to \`dist/\` at their own autoload path, and companion \`.css\`, \`.html\` and
153
- \`.json\` files are copied next to them, so the built output loads like the dev server.
175
+ \`public/\` is the production site: one minified \`bundle.js\` with every companion asset
176
+ inlined by \`loadAssetsPlugin\`, the concatenated \`bundle.css\`, and the page shell. A
177
+ single script tag loads it, no autoloader needed.
178
+
179
+ \`\`\`sh
180
+ pnpm preview
181
+ \`\`\`
154
182
 
155
183
  ## Type-check
156
184
 
@@ -158,8 +186,9 @@ Components compile to \`dist/\` at their own autoload path, and companion \`.css
158
186
  pnpm typecheck
159
187
  \`\`\`
160
188
 
161
- \`tsconfig.json\` checks \`src/\`; \`tsconfig.vc.json\` checks \`vite.config.ts\`, which lives outside
162
- \`src/\` and needs node types for its \`node:\` imports.
189
+ \`tsconfig.json\` compiles \`src/\` into \`build/\`; \`tsconfig.vc.json\` checks
190
+ \`vite.config.ts\`, which lives outside \`src/\` and needs node types for its \`node:\`
191
+ imports.
163
192
 
164
193
  ## Components
165
194
 
@@ -170,31 +199,36 @@ Add a component with:
170
199
  \`\`\`sh
171
200
  npx mc new ${namespace}-name --target-html --target-css
172
201
  \`\`\`
202
+
203
+ Then add its import to \`src/${namespace}/bundle.ts\` so the production bundle picks it up.
173
204
  `;
174
205
  }
175
206
  function viteConfig(namespace) {
176
207
  return `/**
177
208
  * Dev and build config for a Micro Components project.
178
209
  *
179
- * \`src/\` is the Vite root, so the page shell and every component source are
180
- * served straight from source with no build step. Companion \`.css\`, \`.html\` and
181
- * \`.json\` files are served as raw text — Vite would otherwise answer a \`.html\`
182
- * request with its own transformed page (injecting \`/@vite/client\` into the
183
- * template) and a \`.css\` request with a JS module, neither of which
184
- * \`loadAssets()\` can consume. \`copyCompanions\` mirrors the same files into
185
- * \`dist/\` so the built output stays autoload-ready, and every component under
186
- * \`src/<namespace>/\` is a build entry, emitted at its own autoload path
187
- * (\`mc/app.ts\` → \`dist/mc/app.js\`).
210
+ * The chain is \`src/\` → \`build/\` → \`public/\`:
211
+ *
212
+ * - \`build/\` is the \`tsc\` output: one \`.js\` per component at its own autoload path,
213
+ * plus the copied companion assets and the dev shell. The dev server serves it, so
214
+ * the page loads components on demand through \`autoload()\` and nothing is bundled.
215
+ * - \`public/\` is the production output: one minified \`bundle.js\` with every companion
216
+ * asset inlined by \`loadAssetsPlugin\`, the concatenated \`bundle.css\`, and the
217
+ * production shell. A single script tag loads it, no autoloader needed.
188
218
  *
189
219
  * @module ViteConfig
190
220
  */
191
221
 
192
222
  import { defineConfig, type Plugin, type ResolvedConfig } from 'vite';
193
- import { cp, mkdir, readdir, readFile } from 'node:fs/promises';
223
+ import { loadAssetsPlugin } from '@twobirds/microcomponents/vite';
224
+ import { spawn } from 'node:child_process';
225
+ import { cp, mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
194
226
  import path from 'node:path';
195
227
 
196
228
  const projectRoot = import.meta.dirname;
197
229
  const sourceRoot = path.join(projectRoot, 'src');
230
+ const buildRoot = path.join(projectRoot, 'build');
231
+ const publicRoot = path.join(projectRoot, 'public');
198
232
  const namespace = '${namespace}';
199
233
 
200
234
  const pageShells = new Set(['index.html', 'index-dev.html']);
@@ -216,6 +250,84 @@ function isCompanionAsset(name: string): boolean {
216
250
  return name.endsWith('.css') || name.endsWith('.json') || name.endsWith('.html');
217
251
  }
218
252
 
253
+ /**
254
+ * Mirrors companion assets and the dev shell from \`src/\` into the served folder,
255
+ * preserving the tree. Autoload resolves \`<ns-app>\` to \`<ns>/app.js\` relative to the
256
+ * page shell, so the served folder needs the compiled component *and* its companion
257
+ * files at the same relative path.
258
+ *
259
+ * @example
260
+ * \`\`\`ts
261
+ * await mirrorAssets(sourceRoot, buildRoot);
262
+ * \`\`\`
263
+ *
264
+ * @param root Source root to walk.
265
+ * @param out Folder to write into.
266
+ * @param rel Path of the walked directory relative to \`root\`.
267
+ */
268
+ async function mirrorAssets(root: string, out: string, rel = ''): Promise<void> {
269
+ for (const entry of await readdir(path.join(root, rel), { withFileTypes: true })) {
270
+ const next = rel ? path.join(rel, entry.name) : entry.name;
271
+ if (entry.isDirectory()) {
272
+ await mirrorAssets(root, out, next);
273
+ } else if (isCompanionAsset(entry.name) || entry.name === 'index-dev.html') {
274
+ const dst = path.join(out, next);
275
+ await mkdir(path.dirname(dst), { recursive: true });
276
+ await cp(path.join(root, next), dst);
277
+ }
278
+ }
279
+ }
280
+
281
+ /**
282
+ * Runs \`tsc -w\` for the lifetime of the dev server, so a saved component reaches
283
+ * \`build/\` without a second command.
284
+ *
285
+ * @example
286
+ * \`\`\`ts
287
+ * plugins: [tscWatch()],
288
+ * \`\`\`
289
+ */
290
+ function tscWatch(): Plugin {
291
+ let child: ReturnType<typeof spawn> | undefined;
292
+ return {
293
+ name: 'tsc-watch',
294
+ configResolved() {
295
+ child = spawn('npx', ['tsc', '-w', '-p', 'tsconfig.json'], { cwd: projectRoot, stdio: 'inherit' });
296
+ },
297
+ closeServer() {
298
+ child?.kill();
299
+ },
300
+ };
301
+ }
302
+
303
+ /**
304
+ * Mirrors companion assets into \`build/\` before the dev server starts, and again on
305
+ * every change, so the served folder stays autoload-ready.
306
+ *
307
+ * @example
308
+ * \`\`\`ts
309
+ * plugins: [syncBuild()],
310
+ * \`\`\`
311
+ */
312
+ function syncBuild(): Plugin {
313
+ return {
314
+ name: 'sync-build',
315
+ enforce: 'pre',
316
+ async configureServer(server) {
317
+ await mirrorAssets(sourceRoot, buildRoot);
318
+ server.watcher.on('all', async (event, file) => {
319
+ if (!file.startsWith(sourceRoot) || file.endsWith('.ts')) return;
320
+ const rel = path.relative(sourceRoot, file);
321
+ if (isCompanionAsset(path.basename(file)) || file.endsWith('index-dev.html')) {
322
+ const dst = path.join(buildRoot, rel);
323
+ await mkdir(path.dirname(dst), { recursive: true });
324
+ await cp(file, dst);
325
+ }
326
+ });
327
+ },
328
+ };
329
+ }
330
+
219
331
  /**
220
332
  * Serves companion \`.css\`, \`.html\` and \`.json\` files as raw text, ahead of the
221
333
  * Vite transform middleware. Without it a \`.html\` request returns the transformed
@@ -224,10 +336,10 @@ function isCompanionAsset(name: string): boolean {
224
336
  *
225
337
  * @example
226
338
  * \`\`\`ts
227
- * plugins: [rawAssets(sourceRoot)],
339
+ * plugins: [rawAssets(buildRoot)],
228
340
  * \`\`\`
229
341
  *
230
- * @param root Source root that mirrors the \`src/\` tree.
342
+ * @param root Source root that mirrors the served tree.
231
343
  */
232
344
  function rawAssets(root: string): Plugin {
233
345
  return {
@@ -253,109 +365,139 @@ function rawAssets(root: string): Plugin {
253
365
  }
254
366
 
255
367
  /**
256
- * Copies every companion asset from \`src/\` into the build output, preserving the
257
- * directory tree. Autoload resolves \`<ns-app>\` to \`<ns>/app.js\` relative to the
258
- * page shell, so \`dist/\` needs the compiled component *and* its companion files
259
- * at the same relative path.
368
+ * Serves the autoload shell (\`index-dev.html\`) at \`/\`. Vite answers \`/\` with
369
+ * \`index.html\`, which is the production shell referencing \`bundle.js\` — that file
370
+ * only exists in \`public/\`, so the dev server has to redirect to the dev shell.
260
371
  *
261
372
  * @example
262
373
  * \`\`\`ts
263
- * plugins: [copyCompanions(sourceRoot)],
374
+ * plugins: [devShell()],
264
375
  * \`\`\`
376
+ */
377
+ function devShell(): Plugin {
378
+ return {
379
+ name: 'dev-shell',
380
+ enforce: 'pre',
381
+ configureServer(server) {
382
+ server.middlewares.use((req, res, next) => {
383
+ const url = req.url?.split('?')[0];
384
+ if (url !== '/' && url !== '/index.html') return next();
385
+ req.url = '/index-dev.html';
386
+ next();
387
+ });
388
+ },
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Concatenates the plain \`.css\` token presets under \`src/\` into \`bundle.css\`,
394
+ * skipping the companion \`.target.css\` and \`.shadow.css\` files, which
395
+ * \`loadAssetsPlugin\` inlines into the JS bundle.
265
396
  *
266
- * @param root Source root to walk.
397
+ * @example
398
+ * \`\`\`ts
399
+ * plugins: [bundleCss()],
400
+ * \`\`\`
267
401
  */
268
- function copyCompanions(root: string): Plugin {
402
+ function bundleCss(): Plugin {
269
403
  let outDir = '';
270
404
  return {
271
- name: 'copy-companions',
405
+ name: 'bundle-css',
272
406
  apply: 'build',
273
407
  configResolved(config: ResolvedConfig) {
274
408
  outDir = path.resolve(config.root, config.build.outDir);
275
409
  },
276
410
  async closeBundle() {
277
- async function walk(dir: string, rel: string): Promise<void> {
278
- for (const entry of await readdir(dir, { withFileTypes: true })) {
279
- const src = path.join(dir, entry.name);
411
+ async function collect(dir: string, rel: string, found: string[]): Promise<void> {
412
+ for (const entry of await readdir(path.join(dir, rel), { withFileTypes: true })) {
413
+ const next = rel ? path.join(rel, entry.name) : entry.name;
280
414
  if (entry.isDirectory()) {
281
- await walk(src, path.join(rel, entry.name));
282
- } else if (isCompanionAsset(entry.name)) {
283
- const dst = path.join(outDir, rel, entry.name);
284
- await mkdir(path.dirname(dst), { recursive: true });
285
- await cp(src, dst);
415
+ await collect(path.join(dir, rel), next, found);
416
+ } else if (entry.name.endsWith('.css') && !/\\.(target|shadow)\\.css$/.test(entry.name)) {
417
+ found.push(path.join(dir, next));
286
418
  }
287
419
  }
288
420
  }
289
- await walk(root, '');
421
+ const files: string[] = [];
422
+ await collect(sourceRoot, '', files);
423
+ const parts = await Promise.all(files.map((f) => readFile(f, 'utf-8')));
424
+ await mkdir(outDir, { recursive: true });
425
+ await writeFile(path.join(outDir, 'bundle.css'), parts.join('\\n'), 'utf-8');
290
426
  },
291
427
  };
292
428
  }
293
429
 
294
430
  /**
295
- * Names a build chunk after its source path, so a component lands at the autoload
296
- * path (\`src/mc/app.ts\` → \`dist/mc/app.js\`). Rollup truncates chunk names at the
297
- * last \`/\`, so \`info.name\` alone cannot carry a folder — the facade module id is
298
- * the reliable source.
431
+ * Copies the production shell into \`public/\` next to the bundle, so the served
432
+ * folder is a complete static site.
299
433
  *
300
434
  * @example
301
435
  * \`\`\`ts
302
- * entryName({ name: 'mc/app', facadeModuleId: '/project/src/mc/app.ts' });
303
- * entryName({ name: 'index', facadeModuleId: '/project/src/index.html' });
436
+ * plugins: [copyShell()],
304
437
  * \`\`\`
305
- *
306
- * @param info Rollup chunk info.
307
438
  */
308
- function entryName(info: { name: string; facadeModuleId: string | null }): string {
309
- const facade = info.facadeModuleId;
310
- if (!facade || facade.endsWith('.html')) return \`\${info.name}.js\`;
311
- return path.relative(sourceRoot, facade).replace(/\\\\/g, '/').replace(/\\.(ts|js)$/, '.js');
439
+ function copyShell(): Plugin {
440
+ let outDir = '';
441
+ return {
442
+ name: 'copy-shell',
443
+ apply: 'build',
444
+ configResolved(config: ResolvedConfig) {
445
+ outDir = path.resolve(config.root, config.build.outDir);
446
+ },
447
+ async closeBundle() {
448
+ await cp(path.join(sourceRoot, 'index.html'), path.join(outDir, 'index.html'));
449
+ },
450
+ };
312
451
  }
313
452
 
314
- /**
315
- * Collects every component source as a build entry, keyed by its path relative to
316
- * \`src/\`. Plain \`.js\` files count too, so the rapid-prototyping path stays
317
- * buildable. New components are picked up automatically — no config edit needed.
318
- *
319
- * @example
320
- * \`\`\`ts
321
- * const entries: Record<string, string> = {};
322
- * await collectEntries(path.join(sourceRoot, 'mc'), 'mc', entries);
323
- * \`\`\`
324
- *
325
- * @param dir Directory to walk.
326
- * @param rel Path of that directory relative to \`src/\`.
327
- * @param entries Accumulated entries, mutated in place.
328
- */
329
- async function collectEntries(dir: string, rel: string, entries: Record<string, string>): Promise<void> {
330
- for (const entry of await readdir(dir, { withFileTypes: true })) {
331
- const full = path.join(dir, entry.name);
332
- if (entry.isDirectory()) {
333
- await collectEntries(full, path.join(rel, entry.name), entries);
334
- } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) {
335
- entries[path.join(rel, entry.name).replace(/\\.(ts|js)$/, '')] = full;
336
- }
453
+ export default defineConfig(({ command }) => {
454
+ if (command === 'serve') {
455
+ return {
456
+ root: buildRoot,
457
+ server: { port: 5173, open: false },
458
+ plugins: [syncBuild(), tscWatch(), rawAssets(buildRoot), devShell()],
459
+ };
337
460
  }
338
- }
339
-
340
- export default defineConfig(async () => {
341
- const entries: Record<string, string> = {};
342
- await collectEntries(path.join(sourceRoot, namespace), namespace, entries);
343
461
  return {
344
462
  root: sourceRoot,
345
463
  build: {
346
- outDir: path.join(projectRoot, 'dist'),
464
+ outDir: publicRoot,
347
465
  emptyOutDir: true,
466
+ minify: true,
467
+ target: 'esnext',
468
+ sourcemap: false,
469
+ lib: {
470
+ entry: path.join(sourceRoot, namespace, 'bundle.ts'),
471
+ formats: ['es'],
472
+ fileName: () => 'bundle.js',
473
+ },
348
474
  rollupOptions: {
349
- input: { index: path.join(sourceRoot, 'index.html'), ...entries },
350
- output: { entryFileNames: (info: { name: string; facadeModuleId: string | null }) => entryName(info) },
475
+ treeshake: false,
476
+ output: { comments: false, keepNames: true },
351
477
  },
352
478
  },
353
- plugins: [rawAssets(sourceRoot), copyCompanions(sourceRoot)],
479
+ plugins: [loadAssetsPlugin(sourceRoot), bundleCss(), copyShell()],
354
480
  };
355
481
  });
356
482
  `;
357
483
  }
358
- function shell(namespace, tag) {
484
+ function prodShell(tag) {
485
+ return `<!doctype html>
486
+ <html lang="en">
487
+ <head>
488
+ <meta charset="UTF-8" />
489
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
490
+ <title>${tag.replace('-app', ' App')}</title>
491
+ <script type="module" src="bundle.js"></script>
492
+ <link rel="stylesheet" href="./bundle.css" />
493
+ </head>
494
+ <body>
495
+ <${tag}></${tag}>
496
+ </body>
497
+ </html>
498
+ `;
499
+ }
500
+ function devShell(namespace, tag) {
359
501
  return `<!doctype html>
360
502
  <html lang="en">
361
503
  <head>
@@ -374,6 +516,13 @@ function shell(namespace, tag) {
374
516
  </html>
375
517
  `;
376
518
  }
519
+ function bundle() {
520
+ return `// Barrel entry for the production bundle: imports every component so the bundler
521
+ // registers them all in one file. Add a line here when a component is created.
522
+
523
+ import './app.js';
524
+ `;
525
+ }
377
526
  function appSource(namespace, tag) {
378
527
  const className = kebabToPascal(tag);
379
528
  return `import { DC, defineCE, parse, processAssets, type McEvent } from '@twobirds/microcomponents';
package/dist/mcp/api.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
- "libraryVersion": "0.6.0",
3
- "exportedCount": 41,
2
+ "libraryVersion": "0.8.0",
3
+ "exportedCount": 42,
4
4
  "entries": {
5
5
  "MC": {
6
6
  "name": "MC",
@@ -429,7 +429,7 @@
429
429
  "signatures": [
430
430
  "export function trigger<D = unknown>( target: HTMLElement | string | undefined | null, event: string, data: D = {} as D, bubble: BubbleDirections = 'l', // direction of bubbling controller?: BubbleController ) {"
431
431
  ],
432
- "description": "Creates and dispatches a custom event on the specified DOM node.\n\nThe event is namespaced with an `_mc` prefix by default, unless already prefixed.\nSupports bidirectional bubbling (up/down the DOM tree) based on the `bubble` parameter:\n- 'l': local dispatch only\n- 'u': bubble up towards ancestors\n- 'd': bubble down to descendants\n\nThe dispatched event is a `CustomEvent<McDetail<D>>`: handlers find the payload\nunder `ev.detail.data` and can call `ev.detail.control.stop()` to cancel bubbling.\n\nThe first dispatch fires locally only; the second bubbles upward to ancestor components.",
432
+ "description": "Creates and dispatches a custom event on the specified DOM node.\n\nThe event is namespaced with an `_mc` prefix by default, unless already prefixed.\nSupports bidirectional bubbling (up/down the DOM tree) based on the `bubble` parameter:\n- 'l': local dispatch only\n- 'u': bubble up towards ancestors\n- 'd': bubble down to descendants\n\nThe dispatched event is a `CustomEvent<McDetail<D>>`: handlers find the payload\nunder `ev.detail.data` and can call `ev.detail.control.stop()` to cancel bubbling.\n\nPrefer a direct target over bubbling: name the receiving element in `target` and keep the\ndefault `'l'`, so one `querySelectorAll` reaches it in a single step. Reserve `'u'`/`'d'`\nfor receivers that cannot be named in advance, since they walk the tree and re-dispatch the\nevent at every level. Pass a typed object as `data` rather than a bare string, so the\nhandler reads named fields from `ev.detail.data` and the flow stays readable as it grows.\n\nThe first dispatch fires locally only; the second bubbles upward to ancestor components.",
433
433
  "examples": [
434
434
  {
435
435
  "title": "",
@@ -595,6 +595,21 @@
595
595
  "export type McRemoved<T extends HTMLElement, K extends string> = T extends { _mc: McController } ? { _mc: Record<K, never> } : unknown;"
596
596
  ],
597
597
  "description": "The type `remove()` asserts onto the caller's reference: on an enhanced target the\nremoved key is poisoned to `never` (member access becomes a compile error); a\nplain target is left untouched. A later `add()` re-registers the key cleanly."
598
+ },
599
+ "loadAssetsPlugin": {
600
+ "name": "loadAssetsPlugin",
601
+ "kind": "function",
602
+ "file": "src/vite/loadAssets.ts",
603
+ "signatures": [
604
+ "export function loadAssetsPlugin(rootDir: string): PluginOption {"
605
+ ],
606
+ "description": "Vite plugin factory that inlines companion `.css` / `.html` assets into the\nDC classes declaring them through `static assets = [...]`.",
607
+ "examples": [
608
+ {
609
+ "title": "Register the plugin in a Vite config, pointing at the source root.",
610
+ "code": "import { loadAssetsPlugin } from './vite-plugin-load-assets';\nexport default defineConfig({ plugins: [loadAssetsPlugin(__dirname)] });"
611
+ }
612
+ ]
598
613
  }
599
614
  },
600
615
  "undocumented": []
@@ -22,6 +22,29 @@ Bubble directions in trigger():
22
22
 
23
23
  Only use bubbling when there is a specific reason for it. Usually directly addressing the target is better, because of the speed advantage using querySelectorAll().
24
24
 
25
+ ## Choosing the target
26
+
27
+ Name the receiving element in the trigger call and keep the default `'l'`, so a single
28
+ `querySelectorAll` reaches it in one step. `'u'` and `'d'` walk the tree and re-dispatch the
29
+ event at every level, so reserve them for receivers you cannot name in advance — a plugin
30
+ listening to unknown descendants, or a widget whose host markup is not yours.
31
+
32
+ The template declares the handler, the handler builds the payload, the trigger carries it:
33
+
34
+ ```html
35
+ <button class="remove" click="|remove">✕</button>
36
+ ```
37
+
38
+ ```ts
39
+ onRemove(): void {
40
+ trigger('todo-list', 'remove', { id: this.data.id });
41
+ }
42
+ ```
43
+
44
+ Pass an object, not a bare string, so the receiver reads named fields (`ev.detail.data.id`)
45
+ and the flow stays readable as the payload grows. Declare the events on the component's event
46
+ map (`EM` on `DC`) so the payload type is inferred at both ends.
47
+
25
48
  ## Payload & propagation
26
49
 
27
50
  MC-triggered custom events and lifecycle events are `McEvent<D>` — a `CustomEvent` whose `detail` is `{ data, control }`. The payload is always read from `ev.detail.data`, never `ev.data`.
@@ -0,0 +1,37 @@
1
+ A Micro Components project keeps one folder per stage of the chain:
2
+
3
+ ```
4
+ src/ → (tsc) → build/ → (bundle) → public/
5
+ ```
6
+
7
+ - `src/` holds the authored sources: component `.ts` files, the companion
8
+ `.target.html` / `.target.css` / `.shadow.css` assets, the dev shell
9
+ (`index-dev.html`) and the production shell (`index.html`).
10
+ - `build/` is the `tsc` output: one `.js` per component at its own autoload path,
11
+ plus the mirrored companion assets and the dev shell. The dev server serves it, so
12
+ the page loads components on demand through `autoload()` and nothing is bundled.
13
+ - `public/` is the production site: one minified `bundle.js` with every companion
14
+ asset inlined by `loadAssetsPlugin`, the concatenated `bundle.css`, and the
15
+ production shell. A single script tag loads it; the autoloader is not needed.
16
+
17
+ `build/` and `public/` are generated, so they stay out of git.
18
+
19
+ A website that ships with the project — a playground, a component browser, a docs page —
20
+ lives in `src/` like any other page, is served from `build/` while developing, and is
21
+ served from `public/` in production. Do not add a fourth tree for it.
22
+
23
+ A new component is picked up by the dev server automatically, because `autoload()`
24
+ resolves its tag to a file under `build/`. The bundle is not: the bundler walks a single
25
+ entry, so every component must be imported by the barrel `src/<namespace>/bundle.ts`.
26
+
27
+ ## Shells
28
+
29
+ The two shells are deliberately different:
30
+
31
+ - `index-dev.html` imports the library and calls `autoload(true)`, so a tag in the markup
32
+ pulls its own `.js` from `build/`.
33
+ - `index.html` loads `bundle.js` and `bundle.css`, which only exist in `public/`.
34
+
35
+ The dev server redirects `/` to `index-dev.html`; the production build copies
36
+ `index.html` next to the bundle. Serving the production shell in dev would request a
37
+ `bundle.js` that was never built.
@@ -170,6 +170,12 @@ export declare class BubbleController implements McBubbleController {
170
170
  * The dispatched event is a `CustomEvent<McDetail<D>>`: handlers find the payload
171
171
  * under `ev.detail.data` and can call `ev.detail.control.stop()` to cancel bubbling.
172
172
  *
173
+ * Prefer a direct target over bubbling: name the receiving element in `target` and keep the
174
+ * default `'l'`, so one `querySelectorAll` reaches it in a single step. Reserve `'u'`/`'d'`
175
+ * for receivers that cannot be named in advance, since they walk the tree and re-dispatch the
176
+ * event at every level. Pass a typed object as `data` rather than a bare string, so the
177
+ * handler reads named fields from `ev.detail.data` and the flow stays readable as it grows.
178
+ *
173
179
  * @param target - The DOM element on which to trigger the event, or a querySelectorAll(selector) selector string.
174
180
  * @param event - The name of the event (will be prefixed if needed).
175
181
  * @param data - Additional data payload attached to `event.detail.data`.
@@ -1 +1 @@
1
- {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/utils/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiDE;AAIF,OAAO,KAAK,EACX,gBAAgB,EAChB,kBAAkB,EAElB,cAAc,EACd,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAK5E;AAED;;;;;;;;EAQE;AACF,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAEzC;;;;;;;;;;;;;EAaE;AACF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,mBAAmB,GAAG,SAAS,GAAG,IAAI,CAAC;AAEhF,wBAAgB,GAAG,CAAC,CAAC,GAAG,OAAO,EAC9B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GACxB,IAAI,CAAC;AACR,wBAAgB,GAAG,CAClB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,GACzC,IAAI,CAAC;AA4CR,wBAAgB,EAAE,CAAC,CAAC,GAAG,OAAO,EAC7B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAC1B,IAAI,CAAC,EAAE,OAAO,GACZ,cAAc,CAAC,CAAC,CAAC,CAAC;AACrB,wBAAgB,EAAE,CACjB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,EAC3C,IAAI,CAAC,EAAE,OAAO,GACZ,kCAAkC,CAAC;AAsDtC,wBAAgB,GAAG,CAAC,CAAC,GAAG,OAAO,EAC9B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GACxB,cAAc,CAAC,CAAC,CAAC,CAAC;AACrB,wBAAgB,GAAG,CAClB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,GACzC,kCAAkC,CAAC;AA2CtC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,gBAAiB,YAAW,kBAAkB;IAC1D,OAAO,UAAS;;IAEhB;;;;;;;;;;;;;;OAcG;IACH,IAAI;CAGJ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,OAAO,CAAC,CAAC,GAAG,OAAO,EAClC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,EAC/C,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,CAAW,EACjB,MAAM,GAAE,gBAAsB,EAAE,wBAAwB;AACxD,UAAU,CAAC,EAAE,gBAAgB,QA+C7B"}
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../../src/utils/events.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;EAiDE;AAIF,OAAO,KAAK,EACX,gBAAgB,EAChB,kBAAkB,EAElB,cAAc,EACd,MAAM,qBAAqB,CAAC;AAC7B,YAAY,EAAE,gBAAgB,EAAE,MAAM,qBAAqB,CAAC;AAE5D;;;;;;;;;;;;GAYG;AACH,wBAAgB,cAAc,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAExD;AAED;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AACH,wBAAgB,aAAa,CAAC,MAAM,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,GAAG,MAAM,CAK5E;AAED;;;;;;;;EAQE;AACF,MAAM,MAAM,mBAAmB,GAAG,MAAM,CAAC;AAEzC;;;;;;;;;;;;;EAaE;AACF,MAAM,MAAM,YAAY,GAAG,WAAW,GAAG,mBAAmB,GAAG,SAAS,GAAG,IAAI,CAAC;AAEhF,wBAAgB,GAAG,CAAC,CAAC,GAAG,OAAO,EAC9B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GACxB,IAAI,CAAC;AACR,wBAAgB,GAAG,CAClB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,GACzC,IAAI,CAAC;AA4CR,wBAAgB,EAAE,CAAC,CAAC,GAAG,OAAO,EAC7B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,EAC1B,IAAI,CAAC,EAAE,OAAO,GACZ,cAAc,CAAC,CAAC,CAAC,CAAC;AACrB,wBAAgB,EAAE,CACjB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,EAC3C,IAAI,CAAC,EAAE,OAAO,GACZ,kCAAkC,CAAC;AAsDtC,wBAAgB,GAAG,CAAC,CAAC,GAAG,OAAO,EAC9B,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,cAAc,CAAC,CAAC,CAAC,GACxB,cAAc,CAAC,CAAC,CAAC,CAAC;AACrB,wBAAgB,GAAG,CAClB,MAAM,EAAE,YAAY,EACpB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,kCAAkC,GACzC,kCAAkC,CAAC;AA2CtC;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,gBAAiB,YAAW,kBAAkB;IAC1D,OAAO,UAAS;;IAEhB;;;;;;;;;;;;;;OAcG;IACH,IAAI;CAGJ;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AACH,wBAAgB,OAAO,CAAC,CAAC,GAAG,OAAO,EAClC,MAAM,EAAE,WAAW,GAAG,MAAM,GAAG,SAAS,GAAG,IAAI,EAC/C,KAAK,EAAE,MAAM,EACb,IAAI,GAAE,CAAW,EACjB,MAAM,GAAE,gBAAsB,EAAE,wBAAwB;AACxD,UAAU,CAAC,EAAE,gBAAgB,QA+C7B"}
@@ -253,6 +253,12 @@ export class BubbleController {
253
253
  * The dispatched event is a `CustomEvent<McDetail<D>>`: handlers find the payload
254
254
  * under `ev.detail.data` and can call `ev.detail.control.stop()` to cancel bubbling.
255
255
  *
256
+ * Prefer a direct target over bubbling: name the receiving element in `target` and keep the
257
+ * default `'l'`, so one `querySelectorAll` reaches it in a single step. Reserve `'u'`/`'d'`
258
+ * for receivers that cannot be named in advance, since they walk the tree and re-dispatch the
259
+ * event at every level. Pass a typed object as `data` rather than a bare string, so the
260
+ * handler reads named fields from `ev.detail.data` and the flow stays readable as it grows.
261
+ *
256
262
  * @param target - The DOM element on which to trigger the event, or a querySelectorAll(selector) selector string.
257
263
  * @param event - The name of the event (will be prefixed if needed).
258
264
  * @param data - Additional data payload attached to `event.detail.data`.
@@ -0,0 +1,7 @@
1
+ /**
2
+ Vite module — build-time plugins for a bundled Micro Components project.
3
+
4
+ @module Vite
5
+ */
6
+ export { loadAssetsPlugin } from './loadAssets.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/vite/index.ts"],"names":[],"mappings":"AAAA;;;;EAIE;AAEF,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC"}
@@ -0,0 +1,6 @@
1
+ /**
2
+ Vite module — build-time plugins for a bundled Micro Components project.
3
+
4
+ @module Vite
5
+ */
6
+ export { loadAssetsPlugin } from './loadAssets.js';
@@ -0,0 +1,36 @@
1
+ /**
2
+ Inlines `static assets = [...]` declarations on DC classes for a bundled build: companion
3
+ `.css` / `.html` files are read at build time and embedded as a `static _assets = { ... }`
4
+ property inserted as the first line inside the class body, so `loadAssets()` never fetches
5
+ them at runtime. The original `static assets` line is preserved for debugging purposes.
6
+
7
+ The plugin ships as its own entry, `@twobirds/microcomponents/vite`, so a project that
8
+ bundles to `public/` imports it without copying the source.
9
+
10
+ @example
11
+ ```ts
12
+ import { loadAssetsPlugin } from '@twobirds/microcomponents/vite';
13
+
14
+ export default defineConfig({
15
+ plugins: [loadAssetsPlugin(sourceRoot)],
16
+ });
17
+ ```
18
+
19
+ @module Vite
20
+ */
21
+ import type { PluginOption } from 'vite';
22
+ /**
23
+ * Vite plugin factory that inlines companion `.css` / `.html` assets into the
24
+ * DC classes declaring them through `static assets = [...]`.
25
+ *
26
+ * @example Register the plugin in a Vite config, pointing at the source root.
27
+ * ```ts
28
+ * import { loadAssetsPlugin } from './vite-plugin-load-assets';
29
+ * export default defineConfig({ plugins: [loadAssetsPlugin(__dirname)] });
30
+ * ```
31
+ *
32
+ * @param rootDir Directory holding the component source files.
33
+ * @returns A Vite plugin option.
34
+ */
35
+ export declare function loadAssetsPlugin(rootDir: string): PluginOption;
36
+ //# sourceMappingURL=loadAssets.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loadAssets.d.ts","sourceRoot":"","sources":["../../src/vite/loadAssets.ts"],"names":[],"mappings":"AACA;;;;;;;;;;;;;;;;;;;EAmBE;AAIF,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,MAAM,CAAC;AAgJzC;;;;;;;;;;;;GAYG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,MAAM,GAAG,YAAY,CAiE9D"}
@@ -0,0 +1,221 @@
1
+ // oxlint-disable no-await-in-loop
2
+ /**
3
+ Inlines `static assets = [...]` declarations on DC classes for a bundled build: companion
4
+ `.css` / `.html` files are read at build time and embedded as a `static _assets = { ... }`
5
+ property inserted as the first line inside the class body, so `loadAssets()` never fetches
6
+ them at runtime. The original `static assets` line is preserved for debugging purposes.
7
+
8
+ The plugin ships as its own entry, `@twobirds/microcomponents/vite`, so a project that
9
+ bundles to `public/` imports it without copying the source.
10
+
11
+ @example
12
+ ```ts
13
+ import { loadAssetsPlugin } from '@twobirds/microcomponents/vite';
14
+
15
+ export default defineConfig({
16
+ plugins: [loadAssetsPlugin(sourceRoot)],
17
+ });
18
+ ```
19
+
20
+ @module Vite
21
+ */
22
+ import { readFile } from 'node:fs/promises';
23
+ import path from 'node:path';
24
+ /**
25
+ * Escape a raw string so it can be safely embedded inside a JS template literal.
26
+ * Backticks and `${` sequences are escaped to prevent breaking the enclosing literal.
27
+ *
28
+ * @example Returns the input with backticks and `${` sequences escaped.
29
+ * ```ts
30
+ * escapeTemplateLiteral('price is ${cost}`done`');
31
+ * ```
32
+ */
33
+ function escapeTemplateLiteral(raw) {
34
+ return raw.replace(/\\/g, '\\\\').replace(/\$/g, '\\$').replace(/`/g, '\\`');
35
+ }
36
+ /**
37
+ * Resolve the relative file path for a given asset descriptor and component tag.
38
+ * Only the last segment of the tag is used as the basename since the .ts source
39
+ * already lives in the correct directory (e.g. `cart-container-checkout.ts` is
40
+ * inside `cart/container/`, so we just need `checkout.target.html`).
41
+ *
42
+ * @example Returns `./header.shadow.css`, `./card.target.html` and `./summary.css` for the calls below.
43
+ * ```ts
44
+ * resolveAssetPath('shop-header', 'shadowCss');
45
+ * resolveAssetPath('product-card', 'targetHtml');
46
+ * resolveAssetPath('cart-summary', 'css');
47
+ * ```
48
+ */
49
+ function resolveAssetPath(tagName, asset) {
50
+ const basename = tagName.split('-').pop() ?? tagName;
51
+ const m = asset.match(/^([a-z]+)([A-Z][a-z]*)$/);
52
+ if (m) {
53
+ return `./${basename}.${m[1]}.${m[2].toLowerCase()}`;
54
+ }
55
+ return `./${basename}.${asset.toLowerCase()}`;
56
+ }
57
+ /**
58
+ * Extract the tag name from a `defineCE('tag-name', ...)` call in source code.
59
+ * Returns the first found tag or undefined.
60
+ *
61
+ * @example Returns `'shop-header'` for the code below.
62
+ * ```ts
63
+ * extractDefineCETag("defineCE('shop-header', ShopHeader);");
64
+ * ```
65
+ */
66
+ function extractDefineCETag(code) {
67
+ const m = code.match(/defineCE\s*\(\s*['"]([^'"]+)['"]/);
68
+ return m ? m[1] : undefined;
69
+ }
70
+ /**
71
+ * Strip all line and block comments from source code, replacing
72
+ * their content with equivalent whitespace so character positions stay valid for
73
+ * later string slicing.
74
+ *
75
+ * @example Blanks out the `// note` segment while keeping every character
76
+ * position and line break, so later string slicing stays aligned.
77
+ * ```ts
78
+ * stripComments('const a = 1; // note\nconst b = 2;');
79
+ * ```
80
+ */
81
+ function stripComments(code) {
82
+ let result = '';
83
+ let i = 0;
84
+ while (i < code.length) {
85
+ // Line comment
86
+ if (code[i] === '/' && code[i + 1] === '/') {
87
+ const end = code.indexOf('\n', i);
88
+ if (end === -1) {
89
+ result += ' '.repeat(code.length - i);
90
+ break;
91
+ }
92
+ result += ' '.repeat(end - i) + '\n';
93
+ i = end + 1;
94
+ }
95
+ else if (code[i] === '/' && code[i + 1] === '*') {
96
+ const end = code.indexOf('*/', i + 2);
97
+ if (end === -1) {
98
+ result += ' '.repeat(code.length - i);
99
+ break;
100
+ }
101
+ // Preserve newlines inside block comments to keep line positions valid
102
+ let j = i;
103
+ while (j <= end + 1) {
104
+ result += code[j] === '\n' ? '\n' : ' ';
105
+ j++;
106
+ }
107
+ i = end + 2;
108
+ }
109
+ else {
110
+ result += code[i];
111
+ i++;
112
+ }
113
+ }
114
+ return result;
115
+ }
116
+ /**
117
+ * Read and inline all assets for a given tag, returning an array of
118
+ * `{ key, literal }` entries suitable for building object literals.
119
+ * Plain `.css` files hold CSS custom-property presets and are inlined with
120
+ * their real content so the component injects the default variables at
121
+ * runtime; the same files are also merged into bundle.css as the override
122
+ * placeholder. They are never left empty — an empty string would make the
123
+ * runtime treat the asset as not-yet-loaded and re-fetch it.
124
+ *
125
+ * @example Returns one escaped template literal per descriptor, ready to be joined into an object.
126
+ * ```ts
127
+ * readAssetsInline('/src/shop/header', 'shop-header', ['shadowCss', 'targetHtml']);
128
+ * ```
129
+ *
130
+ * @throws If a companion file for a descriptor does not exist.
131
+ */
132
+ async function readAssetsInline(dir, tagName, assets) {
133
+ const entries = [];
134
+ for (const asset of assets) {
135
+ const relPath = resolveAssetPath(tagName, asset);
136
+ const filePath = path.resolve(dir, relPath);
137
+ try {
138
+ const content = await readFile(filePath, 'utf8');
139
+ entries.push({
140
+ key: asset,
141
+ literal: '`' +
142
+ escapeTemplateLiteral(content)
143
+ .replace(/[\n\t\r]/g, ' ')
144
+ .replace(/\s\s/g, ' ') +
145
+ '`',
146
+ });
147
+ }
148
+ catch (_err) {
149
+ throw new Error(`[load-assets] Cannot resolve asset "${asset}" for component "${tagName}": file not found at ${filePath}`, { cause: _err });
150
+ }
151
+ }
152
+ return entries;
153
+ }
154
+ /**
155
+ * Vite plugin factory that inlines companion `.css` / `.html` assets into the
156
+ * DC classes declaring them through `static assets = [...]`.
157
+ *
158
+ * @example Register the plugin in a Vite config, pointing at the source root.
159
+ * ```ts
160
+ * import { loadAssetsPlugin } from './vite-plugin-load-assets';
161
+ * export default defineConfig({ plugins: [loadAssetsPlugin(__dirname)] });
162
+ * ```
163
+ *
164
+ * @param rootDir Directory holding the component source files.
165
+ * @returns A Vite plugin option.
166
+ */
167
+ export function loadAssetsPlugin(rootDir) {
168
+ return {
169
+ name: 'load-assets-inline',
170
+ async transform(code, id) {
171
+ if (!code.includes('static assets'))
172
+ return null;
173
+ const dir = path.dirname(id);
174
+ // Strip comments so JSDoc examples don't trigger false matches.
175
+ // Whitespace-preserving replacement keeps character indices valid.
176
+ const stripped = stripComments(code);
177
+ // Resolve the component tag name from defineCE(...) call
178
+ const tagName = extractDefineCETag(stripped);
179
+ if (!tagName)
180
+ return null;
181
+ // Match: static assets = [ 'asset1' , 'asset2' ];
182
+ const saRegex = /static\s+assets\s*=\s*\[(.*?)\]\s*;?/gs;
183
+ const saMatches = [...stripped.matchAll(saRegex)];
184
+ if (saMatches.length === 0)
185
+ return null;
186
+ let transformed = code;
187
+ // There should be exactly one per file, but handle all in reverse
188
+ for (let i = saMatches.length - 1; i >= 0; i--) {
189
+ const match = saMatches[i];
190
+ const assetListRaw = match[1];
191
+ const assets = assetListRaw
192
+ .split(',')
193
+ .map((a) => a.trim().replace(/^['"]|['"]$/g, ''))
194
+ .filter(Boolean);
195
+ if (assets.length === 0)
196
+ continue;
197
+ // Read and inline all asset contents
198
+ const entries = await readAssetsInline(dir, tagName, assets);
199
+ // Build the static _assets property to inject as first line in class body
200
+ const objEntries = entries.map((e) => `${e.key}: ${e.literal}`);
201
+ const assetsObj = `{ ${objEntries.join(', ')} }`;
202
+ // Find the opening brace of the class that owns this `static assets` declaration.
203
+ // Search backwards from the match position to locate "class ... {" — we need the
204
+ // index of the opening brace so we can insert right after it.
205
+ const beforeMatch = stripped.substring(0, match.index);
206
+ const classOpenMatch = /class\s+\w+[^{]*\{/.exec(beforeMatch);
207
+ if (!classOpenMatch)
208
+ continue;
209
+ // Absolute position of the opening brace in `transformed` (= same as in `stripped`)
210
+ const classBraceIndex = classOpenMatch.index + classOpenMatch[0].length - 1;
211
+ // Build injection code — static _assets as first line inside the class body
212
+ const injectCode = `\n static _assets = ${assetsObj};\n`;
213
+ transformed =
214
+ transformed.substring(0, classBraceIndex + 1) +
215
+ injectCode +
216
+ transformed.substring(classBraceIndex + 1);
217
+ }
218
+ return transformed;
219
+ },
220
+ };
221
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twobirds/microcomponents",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Micro Components",
5
5
  "keywords": [
6
6
  "PWA",
@@ -73,6 +73,10 @@
73
73
  "types": "./dist/mcp/index.d.ts",
74
74
  "import": "./dist/mcp/index.js"
75
75
  },
76
+ "./vite": {
77
+ "types": "./dist/vite/index.d.ts",
78
+ "import": "./dist/vite/index.js"
79
+ },
76
80
  "./package.json": "./package.json"
77
81
  },
78
82
  "scripts": {