@twobirds/microcomponents 0.7.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
@@ -75,6 +75,20 @@ There is no hand-written API list in the server. The whole index is extracted fr
75
75
 
76
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.
77
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
+
78
92
  ## CLI
79
93
 
80
94
  A small command-line tool scaffolds new components so the tag-to-file convention is never hand-written:
@@ -95,21 +109,25 @@ pnpm run cli -- new my-card --target-html --target-css --dry-run
95
109
  # would create src/my/card.target.css
96
110
  ```
97
111
 
98
- `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)`, a starter component with companion assets, and an `opencode.jsonc` that registers the MCP server:
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/`:
99
113
 
100
114
  ```bash
101
115
  pnpm run cli -- create --namespace mc --dir mc-app
102
116
  # created mc-app/package.json
103
117
  # created mc-app/opencode.jsonc
104
118
  # created mc-app/src/index.html
119
+ # created mc-app/src/index-dev.html
105
120
  # created mc-app/src/mc/app.ts
106
121
  # created mc-app/src/mc/app.target.html
107
122
  # created mc-app/src/mc/app.target.css
123
+ # created mc-app/src/mc/bundle.ts
108
124
  # ...
109
125
  # project "mc" created in mc-app/ — run: cd mc-app && pnpm install && pnpm dev
110
126
  ```
111
127
 
112
- 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.
113
131
 
114
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.
115
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,CAwBhE"}
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
  */
@@ -64,10 +64,12 @@ export function scaffoldProject(namespace) {
64
64
  files['.gitignore'] = gitignore();
65
65
  files['opencode.jsonc'] = opencodeConfig();
66
66
  files['README.md'] = readme(namespace, tag);
67
- files['src/index.html'] = shell(namespace, tag);
67
+ files['src/index.html'] = prodShell(tag);
68
+ files['src/index-dev.html'] = devShell(namespace, tag);
68
69
  files[`src/${base}.ts`] = appSource(namespace, tag);
69
70
  files[`src/${base}.target.html`] = appHtml(namespace);
70
71
  files[`src/${base}.target.css`] = appCss(tag);
72
+ files[`src/${namespace}/bundle.ts`] = bundle();
71
73
  return { files, namespace };
72
74
  }
73
75
  function packageJson(namespace) {
@@ -78,11 +80,11 @@ function packageJson(namespace) {
78
80
  scripts: {
79
81
  dev: 'vite',
80
82
  build: 'vite build',
81
- preview: 'vite preview',
83
+ preview: 'vite preview --outDir public --port 5174',
82
84
  typecheck: 'tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.vc.json',
83
85
  },
84
86
  dependencies: {
85
- '@twobirds/microcomponents': '^0.7.0',
87
+ '@twobirds/microcomponents': '^0.8.0',
86
88
  },
87
89
  devDependencies: {
88
90
  vite: '^8.1.5',
@@ -101,7 +103,9 @@ function tsconfig() {
101
103
  strict: true,
102
104
  esModuleInterop: true,
103
105
  skipLibCheck: true,
104
- noEmit: true,
106
+ rootDir: 'src',
107
+ outDir: 'build',
108
+ removeComments: true,
105
109
  },
106
110
  include: ['src/**/*.ts'],
107
111
  }, null, '\t');
@@ -124,7 +128,8 @@ function tsconfigVc() {
124
128
  }
125
129
  function gitignore() {
126
130
  return `node_modules/
127
- dist/
131
+ build/
132
+ public/
128
133
  `;
129
134
  }
130
135
  function opencodeConfig() {
@@ -156,14 +161,24 @@ pnpm install
156
161
  pnpm dev
157
162
  \`\`\`
158
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
+
159
169
  ## Build
160
170
 
161
171
  \`\`\`sh
162
172
  pnpm build
163
173
  \`\`\`
164
174
 
165
- Components compile to \`dist/\` at their own autoload path, and companion \`.css\`, \`.html\` and
166
- \`.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
+ \`\`\`
167
182
 
168
183
  ## Type-check
169
184
 
@@ -171,8 +186,9 @@ Components compile to \`dist/\` at their own autoload path, and companion \`.css
171
186
  pnpm typecheck
172
187
  \`\`\`
173
188
 
174
- \`tsconfig.json\` checks \`src/\`; \`tsconfig.vc.json\` checks \`vite.config.ts\`, which lives outside
175
- \`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.
176
192
 
177
193
  ## Components
178
194
 
@@ -183,31 +199,36 @@ Add a component with:
183
199
  \`\`\`sh
184
200
  npx mc new ${namespace}-name --target-html --target-css
185
201
  \`\`\`
202
+
203
+ Then add its import to \`src/${namespace}/bundle.ts\` so the production bundle picks it up.
186
204
  `;
187
205
  }
188
206
  function viteConfig(namespace) {
189
207
  return `/**
190
208
  * Dev and build config for a Micro Components project.
191
209
  *
192
- * \`src/\` is the Vite root, so the page shell and every component source are
193
- * served straight from source with no build step. Companion \`.css\`, \`.html\` and
194
- * \`.json\` files are served as raw text — Vite would otherwise answer a \`.html\`
195
- * request with its own transformed page (injecting \`/@vite/client\` into the
196
- * template) and a \`.css\` request with a JS module, neither of which
197
- * \`loadAssets()\` can consume. \`copyCompanions\` mirrors the same files into
198
- * \`dist/\` so the built output stays autoload-ready, and every component under
199
- * \`src/<namespace>/\` is a build entry, emitted at its own autoload path
200
- * (\`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.
201
218
  *
202
219
  * @module ViteConfig
203
220
  */
204
221
 
205
222
  import { defineConfig, type Plugin, type ResolvedConfig } from 'vite';
206
- 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';
207
226
  import path from 'node:path';
208
227
 
209
228
  const projectRoot = import.meta.dirname;
210
229
  const sourceRoot = path.join(projectRoot, 'src');
230
+ const buildRoot = path.join(projectRoot, 'build');
231
+ const publicRoot = path.join(projectRoot, 'public');
211
232
  const namespace = '${namespace}';
212
233
 
213
234
  const pageShells = new Set(['index.html', 'index-dev.html']);
@@ -229,6 +250,84 @@ function isCompanionAsset(name: string): boolean {
229
250
  return name.endsWith('.css') || name.endsWith('.json') || name.endsWith('.html');
230
251
  }
231
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
+
232
331
  /**
233
332
  * Serves companion \`.css\`, \`.html\` and \`.json\` files as raw text, ahead of the
234
333
  * Vite transform middleware. Without it a \`.html\` request returns the transformed
@@ -237,10 +336,10 @@ function isCompanionAsset(name: string): boolean {
237
336
  *
238
337
  * @example
239
338
  * \`\`\`ts
240
- * plugins: [rawAssets(sourceRoot)],
339
+ * plugins: [rawAssets(buildRoot)],
241
340
  * \`\`\`
242
341
  *
243
- * @param root Source root that mirrors the \`src/\` tree.
342
+ * @param root Source root that mirrors the served tree.
244
343
  */
245
344
  function rawAssets(root: string): Plugin {
246
345
  return {
@@ -266,109 +365,139 @@ function rawAssets(root: string): Plugin {
266
365
  }
267
366
 
268
367
  /**
269
- * Copies every companion asset from \`src/\` into the build output, preserving the
270
- * directory tree. Autoload resolves \`<ns-app>\` to \`<ns>/app.js\` relative to the
271
- * page shell, so \`dist/\` needs the compiled component *and* its companion files
272
- * 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.
273
371
  *
274
372
  * @example
275
373
  * \`\`\`ts
276
- * plugins: [copyCompanions(sourceRoot)],
374
+ * plugins: [devShell()],
277
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.
278
396
  *
279
- * @param root Source root to walk.
397
+ * @example
398
+ * \`\`\`ts
399
+ * plugins: [bundleCss()],
400
+ * \`\`\`
280
401
  */
281
- function copyCompanions(root: string): Plugin {
402
+ function bundleCss(): Plugin {
282
403
  let outDir = '';
283
404
  return {
284
- name: 'copy-companions',
405
+ name: 'bundle-css',
285
406
  apply: 'build',
286
407
  configResolved(config: ResolvedConfig) {
287
408
  outDir = path.resolve(config.root, config.build.outDir);
288
409
  },
289
410
  async closeBundle() {
290
- async function walk(dir: string, rel: string): Promise<void> {
291
- for (const entry of await readdir(dir, { withFileTypes: true })) {
292
- 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;
293
414
  if (entry.isDirectory()) {
294
- await walk(src, path.join(rel, entry.name));
295
- } else if (isCompanionAsset(entry.name)) {
296
- const dst = path.join(outDir, rel, entry.name);
297
- await mkdir(path.dirname(dst), { recursive: true });
298
- 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));
299
418
  }
300
419
  }
301
420
  }
302
- 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');
303
426
  },
304
427
  };
305
428
  }
306
429
 
307
430
  /**
308
- * Names a build chunk after its source path, so a component lands at the autoload
309
- * path (\`src/mc/app.ts\` → \`dist/mc/app.js\`). Rollup truncates chunk names at the
310
- * last \`/\`, so \`info.name\` alone cannot carry a folder — the facade module id is
311
- * the reliable source.
431
+ * Copies the production shell into \`public/\` next to the bundle, so the served
432
+ * folder is a complete static site.
312
433
  *
313
434
  * @example
314
435
  * \`\`\`ts
315
- * entryName({ name: 'mc/app', facadeModuleId: '/project/src/mc/app.ts' });
316
- * entryName({ name: 'index', facadeModuleId: '/project/src/index.html' });
436
+ * plugins: [copyShell()],
317
437
  * \`\`\`
318
- *
319
- * @param info Rollup chunk info.
320
438
  */
321
- function entryName(info: { name: string; facadeModuleId: string | null }): string {
322
- const facade = info.facadeModuleId;
323
- if (!facade || facade.endsWith('.html')) return \`\${info.name}.js\`;
324
- 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
+ };
325
451
  }
326
452
 
327
- /**
328
- * Collects every component source as a build entry, keyed by its path relative to
329
- * \`src/\`. Plain \`.js\` files count too, so the rapid-prototyping path stays
330
- * buildable. New components are picked up automatically — no config edit needed.
331
- *
332
- * @example
333
- * \`\`\`ts
334
- * const entries: Record<string, string> = {};
335
- * await collectEntries(path.join(sourceRoot, 'mc'), 'mc', entries);
336
- * \`\`\`
337
- *
338
- * @param dir Directory to walk.
339
- * @param rel Path of that directory relative to \`src/\`.
340
- * @param entries Accumulated entries, mutated in place.
341
- */
342
- async function collectEntries(dir: string, rel: string, entries: Record<string, string>): Promise<void> {
343
- for (const entry of await readdir(dir, { withFileTypes: true })) {
344
- const full = path.join(dir, entry.name);
345
- if (entry.isDirectory()) {
346
- await collectEntries(full, path.join(rel, entry.name), entries);
347
- } else if (entry.name.endsWith('.ts') || entry.name.endsWith('.js')) {
348
- entries[path.join(rel, entry.name).replace(/\\.(ts|js)$/, '')] = full;
349
- }
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
+ };
350
460
  }
351
- }
352
-
353
- export default defineConfig(async () => {
354
- const entries: Record<string, string> = {};
355
- await collectEntries(path.join(sourceRoot, namespace), namespace, entries);
356
461
  return {
357
462
  root: sourceRoot,
358
463
  build: {
359
- outDir: path.join(projectRoot, 'dist'),
464
+ outDir: publicRoot,
360
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
+ },
361
474
  rollupOptions: {
362
- input: { index: path.join(sourceRoot, 'index.html'), ...entries },
363
- output: { entryFileNames: (info: { name: string; facadeModuleId: string | null }) => entryName(info) },
475
+ treeshake: false,
476
+ output: { comments: false, keepNames: true },
364
477
  },
365
478
  },
366
- plugins: [rawAssets(sourceRoot), copyCompanions(sourceRoot)],
479
+ plugins: [loadAssetsPlugin(sourceRoot), bundleCss(), copyShell()],
367
480
  };
368
481
  });
369
482
  `;
370
483
  }
371
- 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) {
372
501
  return `<!doctype html>
373
502
  <html lang="en">
374
503
  <head>
@@ -387,6 +516,13 @@ function shell(namespace, tag) {
387
516
  </html>
388
517
  `;
389
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
+ }
390
526
  function appSource(namespace, tag) {
391
527
  const className = kebabToPascal(tag);
392
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.7.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.7.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": {