@twobirds/microcomponents 0.9.0 → 0.10.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 +2 -16
- package/dist/cli/bundle.d.ts +106 -0
- package/dist/cli/bundle.d.ts.map +1 -0
- package/dist/cli/bundler.d.ts +44 -0
- package/dist/cli/bundler.d.ts.map +1 -0
- package/dist/cli/create.d.ts.map +1 -1
- package/dist/cli/dev.d.ts +87 -0
- package/dist/cli/dev.d.ts.map +1 -0
- package/dist/cli/dispatch.d.ts +6 -2
- package/dist/cli/dispatch.d.ts.map +1 -1
- package/dist/cli/index.js +1807 -1
- package/dist/cli/inliner.d.ts +42 -0
- package/dist/cli/inliner.d.ts.map +1 -0
- package/dist/cli/preview.d.ts +48 -0
- package/dist/cli/preview.d.ts.map +1 -0
- package/dist/core/elements.js +1294 -75
- package/dist/core/mcGuards.js +5 -8
- package/dist/mcp/api.json +2 -17
- package/dist/mcp/concepts/project-layout.md +9 -8
- package/dist/mcp/index.js +450 -230
- package/dist/microcomponents.js +2882 -9
- package/dist/state/formValues.js +2236 -281
- package/dist/state/observables.js +1794 -342
- package/dist/utils/events.js +171 -197
- package/package.json +3 -7
- package/dist/cli/create.js +0 -653
- package/dist/cli/dispatch.js +0 -316
- package/dist/cli/scaffold.js +0 -109
- package/dist/cli/writeScaffold.js +0 -114
- package/dist/core/MC.js +0 -837
- package/dist/core/index.js +0 -72
- package/dist/state/index.js +0 -5
- package/dist/state/placeholders.js +0 -187
- package/dist/types/index.js +0 -1
- package/dist/types/mcTypes.js +0 -9
- package/dist/utils/autoload.js +0 -160
- package/dist/utils/helpers.js +0 -532
- package/dist/utils/index.js +0 -12
- package/dist/utils/resolveComponentPath.js +0 -39
- package/dist/vite/index.d.ts +0 -7
- package/dist/vite/index.d.ts.map +0 -1
- package/dist/vite/index.js +0 -6
- package/dist/vite/loadAssets.d.ts +0 -36
- package/dist/vite/loadAssets.d.ts.map +0 -1
- package/dist/vite/loadAssets.js +0 -221
package/dist/cli/index.js
CHANGED
|
@@ -1,4 +1,1810 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { cp, mkdir, readFile, readdir, rm, writeFile } from "node:fs/promises";
|
|
5
|
+
import { existsSync, watch } from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { createServer } from "node:http";
|
|
8
|
+
//#region src/utils/resolveComponentPath.ts
|
|
9
|
+
/**
|
|
10
|
+
@module Autoload
|
|
11
|
+
|
|
12
|
+
Single source of truth for the tag-to-file convention: hyphens become path
|
|
13
|
+
separators, so `my-card` resolves to `./my/card.js`. Imported by the runtime
|
|
14
|
+
autoload loader and by build-time tooling so both agree on the path without
|
|
15
|
+
either duplicating the rule.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
The tag-to-path primitive: hyphens become path separators, with no prefix or
|
|
19
|
+
extension. `my-card` → `my/card`. The single source of truth for the
|
|
20
|
+
convention; {@link resolveComponentPath} and build-time tooling both derive
|
|
21
|
+
their paths from it so neither duplicates the rule.
|
|
22
|
+
|
|
23
|
+
@example Returns `'my/card'` for `my-card`, and `'app/header'` for `app-header`.
|
|
24
|
+
```ts
|
|
25
|
+
tagPath('my-card');
|
|
26
|
+
tagPath('app-header');
|
|
27
|
+
```
|
|
28
|
+
@hidden
|
|
29
|
+
@group Autoload
|
|
30
|
+
*/
|
|
31
|
+
function tagPath(tag) {
|
|
32
|
+
return tag.split("-").join("/");
|
|
33
|
+
}
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/cli/scaffold.ts
|
|
36
|
+
/**
|
|
37
|
+
Scaffolding for new micro components.
|
|
38
|
+
|
|
39
|
+
Pure, filesystem-free logic: given a tag name it returns the files to write and
|
|
40
|
+
their contents. The tag-to-path rule is not re-derived here — it comes from
|
|
41
|
+
{@link tagPath}, the same primitive the runtime autoload loader uses, so the
|
|
42
|
+
scaffold and the runtime can never disagree about where a component lives.
|
|
43
|
+
|
|
44
|
+
Companion assets follow the runtime's variant convention (see `loadAssets` /
|
|
45
|
+
`processAssets`): a camelCase descriptor maps to a dotted filename, e.g.
|
|
46
|
+
`'targetHtml'` → `{path}.target.html`, `'shadowCss'` → `{path}.shadow.css`, and
|
|
47
|
+
the unvariant `'css'` → `{path}.css` (the document-level stylesheet where CSS
|
|
48
|
+
custom properties / design tokens live).
|
|
49
|
+
|
|
50
|
+
@module CLI/Scaffold
|
|
51
|
+
*/
|
|
52
|
+
var TAG_RE = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)+$/;
|
|
53
|
+
/**
|
|
54
|
+
Whether a string is a valid micro component tag name.
|
|
55
|
+
|
|
56
|
+
@example
|
|
57
|
+
```ts
|
|
58
|
+
console.log(isValidComponentTag('my-card'));
|
|
59
|
+
console.log(isValidComponentTag('mycard'));
|
|
60
|
+
```
|
|
61
|
+
*/
|
|
62
|
+
function isValidComponentTag(tag) {
|
|
63
|
+
return TAG_RE.test(tag);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
Builds the files for a new micro component from its tag name.
|
|
67
|
+
|
|
68
|
+
@example
|
|
69
|
+
```ts
|
|
70
|
+
const { className, files } = scaffoldComponent('my-card', {
|
|
71
|
+
assets: ['targetHtml', 'targetCss'],
|
|
72
|
+
});
|
|
73
|
+
console.log(className);
|
|
74
|
+
console.log(Object.keys(files));
|
|
75
|
+
```
|
|
76
|
+
@param tag - Kebab-case component tag, e.g. `my-card`.
|
|
77
|
+
@param options - Which companion assets to emit and whether to use `.js`.
|
|
78
|
+
@throws If `tag` is not a valid custom element name.
|
|
79
|
+
*/
|
|
80
|
+
function scaffoldComponent(tag, options = {}) {
|
|
81
|
+
if (!isValidComponentTag(tag)) throw new Error(`invalid component tag "${tag}": must be kebab-case with at least one hyphen (e.g. "my-card")`);
|
|
82
|
+
const className = tag.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
83
|
+
const ext = options.js ? "js" : "ts";
|
|
84
|
+
const ctorParam = options.js ? "target" : "target: HTMLElement";
|
|
85
|
+
const base = tagPath(tag);
|
|
86
|
+
const assets = [...new Set(options.assets ?? [])];
|
|
87
|
+
const files = {};
|
|
88
|
+
files[`${base}.${ext}`] = componentSource(tag, ctorParam, assets);
|
|
89
|
+
for (const kind of assets) files[assetFile(tag, kind)] = assetSource(kind, tag);
|
|
90
|
+
return {
|
|
91
|
+
files,
|
|
92
|
+
className
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function assetFile(tag, kind) {
|
|
96
|
+
const m = kind.match(/^([a-z]+)([A-Z][a-z]*)$/);
|
|
97
|
+
const suffix = m ? `.${m[1]}.${m[2].toLowerCase()}` : `.${kind.toLowerCase()}`;
|
|
98
|
+
return `${tagPath(tag)}${suffix}`;
|
|
99
|
+
}
|
|
100
|
+
function componentSource(tag, ctorParam, assets) {
|
|
101
|
+
const lines = [
|
|
102
|
+
`import { DC, defineCE } from '@twobirds/microcomponents';`,
|
|
103
|
+
``,
|
|
104
|
+
`/**`,
|
|
105
|
+
`\`${tag}\` — describe what this micro component does.`,
|
|
106
|
+
``,
|
|
107
|
+
`@example`,
|
|
108
|
+
"```html",
|
|
109
|
+
`<${tag}></${tag}>`,
|
|
110
|
+
"```",
|
|
111
|
+
`*/`,
|
|
112
|
+
`defineCE(`,
|
|
113
|
+
`\t'${tag}',`,
|
|
114
|
+
`\tclass extends DC {`
|
|
115
|
+
];
|
|
116
|
+
if (assets.length) lines.push(`\t\tstatic assets = [${assets.map((a) => `'${a}'`).join(", ")}];`);
|
|
117
|
+
lines.push(`\t\tconstructor(${ctorParam}) {`, `\t\t\tsuper(target);`, `\t\t}`, `\t}`, `);`, ``);
|
|
118
|
+
return lines.join("\n");
|
|
119
|
+
}
|
|
120
|
+
function assetSource(kind, tag) {
|
|
121
|
+
switch (kind) {
|
|
122
|
+
case "css": return [
|
|
123
|
+
":root {",
|
|
124
|
+
" /* design tokens (CSS custom properties) */",
|
|
125
|
+
"}",
|
|
126
|
+
""
|
|
127
|
+
].join("\n");
|
|
128
|
+
case "targetCss": return [
|
|
129
|
+
`${tag} {`,
|
|
130
|
+
" display: block;",
|
|
131
|
+
"}",
|
|
132
|
+
""
|
|
133
|
+
].join("\n");
|
|
134
|
+
case "shadowCss": return [
|
|
135
|
+
":host {",
|
|
136
|
+
" display: block;",
|
|
137
|
+
"}",
|
|
138
|
+
""
|
|
139
|
+
].join("\n");
|
|
140
|
+
default: return "<slot></slot>\n";
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
//#endregion
|
|
144
|
+
//#region src/utils/helpers.ts
|
|
145
|
+
/**
|
|
146
|
+
* Converts a kebab-case string to PascalCase.
|
|
147
|
+
* Example: 'my-component-name' -> 'MyComponentName'
|
|
148
|
+
* @param str - The kebab-case string to convert.
|
|
149
|
+
* @returns The converted PascalCase string.
|
|
150
|
+
*
|
|
151
|
+
* @example
|
|
152
|
+
* ```ts
|
|
153
|
+
* console.log(kebabToPascal('my-element'));
|
|
154
|
+
* ```
|
|
155
|
+
*/
|
|
156
|
+
function kebabToPascal(str) {
|
|
157
|
+
return str.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join("");
|
|
158
|
+
}
|
|
159
|
+
//#endregion
|
|
160
|
+
//#region src/cli/create.ts
|
|
161
|
+
/**
|
|
162
|
+
Project scaffolding for `mc create --namespace xxx`.
|
|
163
|
+
|
|
164
|
+
Pure, filesystem-free logic: given a namespace it returns the files of a complete,
|
|
165
|
+
working Micro Components dev environment and their contents. The starter component's
|
|
166
|
+
path is not re-derived here — it comes from {@link tagPath}, the same primitive the
|
|
167
|
+
runtime autoload loader uses, so the scaffold and the runtime can never disagree about
|
|
168
|
+
where a component lives.
|
|
169
|
+
|
|
170
|
+
The namespace is the first segment of every component tag, so `xxx` makes `src/xxx/`
|
|
171
|
+
the home of tags like `<xxx-app>` (tagPath `xxx-app` → `xxx/app`). The generated
|
|
172
|
+
environment follows the three-path chain the library's own demos use: `src/` holds the
|
|
173
|
+
authored sources, `build/` receives the per-file `tsc` output that the dev server
|
|
174
|
+
serves through `autoload()`, and `public/` receives the bundled production site. Because
|
|
175
|
+
the dev shell is the autoload base, `import.meta.url` + `tagPath('xxx-app')` resolves to
|
|
176
|
+
`build/xxx/app.js` — the compiled starter.
|
|
177
|
+
|
|
178
|
+
@module CLI/Project
|
|
179
|
+
*/
|
|
180
|
+
/** The starter component tag for a namespace: `xxx` → `xxx-app`. */
|
|
181
|
+
function starterTag(namespace) {
|
|
182
|
+
return `${namespace}-app`;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
Whether a string is a valid project namespace: 1 to 4 lowercase letters.
|
|
186
|
+
|
|
187
|
+
@example
|
|
188
|
+
```ts
|
|
189
|
+
console.log(isValidNamespace('mc'));
|
|
190
|
+
console.log(isValidNamespace('mcxyz'));
|
|
191
|
+
console.log(isValidNamespace('toolong'));
|
|
192
|
+
```
|
|
193
|
+
@param ns - Candidate namespace.
|
|
194
|
+
*/
|
|
195
|
+
function isValidNamespace(ns) {
|
|
196
|
+
return /^[a-z]{1,4}$/.test(ns);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
Builds the files of a complete dev environment for a namespace.
|
|
200
|
+
|
|
201
|
+
@example
|
|
202
|
+
```ts
|
|
203
|
+
const { namespace, files } = scaffoldProject('mc');
|
|
204
|
+
console.log(namespace);
|
|
205
|
+
console.log(Object.keys(files));
|
|
206
|
+
```
|
|
207
|
+
@param namespace - 1 to 4 lowercase letters; becomes the `src/<namespace>/` folder and
|
|
208
|
+
the first segment of every component tag.
|
|
209
|
+
@throws If `namespace` is not 1 to 4 lowercase letters.
|
|
210
|
+
*/
|
|
211
|
+
function scaffoldProject(namespace) {
|
|
212
|
+
if (!isValidNamespace(namespace)) throw new Error(`invalid namespace "${namespace}": must be 1 to 4 lowercase letters (e.g. "mc")`);
|
|
213
|
+
const tag = starterTag(namespace);
|
|
214
|
+
const base = tagPath(tag);
|
|
215
|
+
const files = {};
|
|
216
|
+
files["package.json"] = packageJson(namespace);
|
|
217
|
+
files["tsconfig.json"] = tsconfig();
|
|
218
|
+
files[".gitignore"] = gitignore();
|
|
219
|
+
files["opencode.jsonc"] = opencodeConfig();
|
|
220
|
+
files[".opencode/skills/components/SKILL.md"] = componentSkill(namespace, tag);
|
|
221
|
+
files["README.md"] = readme(namespace, tag);
|
|
222
|
+
files["src/index.html"] = prodShell(tag);
|
|
223
|
+
files["src/index-dev.html"] = devShell(namespace, tag);
|
|
224
|
+
files[`src/${base}.ts`] = appSource(namespace, tag);
|
|
225
|
+
files[`src/${base}.target.html`] = appHtml(namespace);
|
|
226
|
+
files[`src/${base}.target.css`] = appCss(tag);
|
|
227
|
+
files[`src/${namespace}/bundle.ts`] = bundle();
|
|
228
|
+
files["src/bundle.json"] = bundleConfig(namespace);
|
|
229
|
+
return {
|
|
230
|
+
files,
|
|
231
|
+
namespace
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function packageJson(namespace) {
|
|
235
|
+
return JSON.stringify({
|
|
236
|
+
name: `${namespace}-app`,
|
|
237
|
+
private: true,
|
|
238
|
+
type: "module",
|
|
239
|
+
scripts: {
|
|
240
|
+
dev: "mc dev",
|
|
241
|
+
build: "tsc -p tsconfig.json && mc bundle",
|
|
242
|
+
preview: "mc preview",
|
|
243
|
+
typecheck: "tsc --noEmit -p tsconfig.json"
|
|
244
|
+
},
|
|
245
|
+
dependencies: { "@twobirds/microcomponents": "^0.10.0" },
|
|
246
|
+
devDependencies: { typescript: "^6.0.3" }
|
|
247
|
+
}, null, " ");
|
|
248
|
+
}
|
|
249
|
+
function tsconfig() {
|
|
250
|
+
return JSON.stringify({
|
|
251
|
+
compilerOptions: {
|
|
252
|
+
target: "ESNext",
|
|
253
|
+
module: "ESNext",
|
|
254
|
+
moduleResolution: "Bundler",
|
|
255
|
+
lib: [
|
|
256
|
+
"ESNext",
|
|
257
|
+
"DOM",
|
|
258
|
+
"DOM.Iterable"
|
|
259
|
+
],
|
|
260
|
+
strict: true,
|
|
261
|
+
esModuleInterop: true,
|
|
262
|
+
skipLibCheck: true,
|
|
263
|
+
rootDir: "src",
|
|
264
|
+
outDir: "build",
|
|
265
|
+
removeComments: true
|
|
266
|
+
},
|
|
267
|
+
include: ["src/**/*.ts"]
|
|
268
|
+
}, null, " ");
|
|
269
|
+
}
|
|
270
|
+
function gitignore() {
|
|
271
|
+
return `node_modules/
|
|
272
|
+
build/
|
|
273
|
+
public/
|
|
274
|
+
`;
|
|
275
|
+
}
|
|
276
|
+
function opencodeConfig() {
|
|
277
|
+
return JSON.stringify({
|
|
278
|
+
$schema: "https://opencode.ai/config.json",
|
|
279
|
+
lsp: true,
|
|
280
|
+
mcp: { microcomponents: {
|
|
281
|
+
type: "local",
|
|
282
|
+
command: [
|
|
283
|
+
"npx",
|
|
284
|
+
"-p",
|
|
285
|
+
"@twobirds/microcomponents",
|
|
286
|
+
"microcomponents-mcp"
|
|
287
|
+
],
|
|
288
|
+
enabled: true
|
|
289
|
+
} }
|
|
290
|
+
}, null, " ");
|
|
291
|
+
}
|
|
292
|
+
function componentSkill(namespace, tag) {
|
|
293
|
+
return `---
|
|
294
|
+
name: components
|
|
295
|
+
description: Use when adding, editing, naming, or wiring a micro component in this project, registering a component in the bundle, choosing an event dispatch target, running dev or build, or touching build/ or public/.
|
|
296
|
+
---
|
|
297
|
+
|
|
298
|
+
# Micro Components project
|
|
299
|
+
|
|
300
|
+
Namespace \`${namespace}\`. Every tag starts with \`${namespace}-\`. Components live in \`src/${namespace}/\` and the starter is \`<${tag}>\`.
|
|
301
|
+
|
|
302
|
+
## The chain
|
|
303
|
+
|
|
304
|
+
\`src/\` → \`build/\` → \`public/\`
|
|
305
|
+
|
|
306
|
+
- \`src/\` is the only folder to edit.
|
|
307
|
+
- \`build/\` is the \`tsc\` output plus mirrored companion assets, served by the dev server. Generated.
|
|
308
|
+
- \`public/\` is the production site: \`bundle.js\` with companion assets inlined, \`bundle.css\`, \`index.html\`. Generated.
|
|
309
|
+
|
|
310
|
+
Never edit, rename, or delete anything under \`build/\` or \`public/\`. Change \`src/\` and rebuild.
|
|
311
|
+
|
|
312
|
+
## Add a component
|
|
313
|
+
|
|
314
|
+
\`\`\`sh
|
|
315
|
+
npx mc new ${namespace}-name --target-html --target-css
|
|
316
|
+
\`\`\`
|
|
317
|
+
|
|
318
|
+
The tag must be kebab-case with at least one hyphen. \`ns-name\` becomes \`src/ns/name.ts\`: the first hyphen becomes the folder, which is the same rule the autoloader uses.
|
|
319
|
+
|
|
320
|
+
Register the component so the production bundle picks it up:
|
|
321
|
+
|
|
322
|
+
\`\`\`ts
|
|
323
|
+
// src/${namespace}/bundle.ts
|
|
324
|
+
import './name.js';
|
|
325
|
+
\`\`\`
|
|
326
|
+
|
|
327
|
+
The bundler reads \`src/bundle.json\`; its \`entry\` points at this barrel, so the barrel is what \`public/bundle.js\` is built from.
|
|
328
|
+
|
|
329
|
+
A component missing from the barrel loads in dev through \`autoload()\` but is absent from \`public/bundle.js\`.
|
|
330
|
+
|
|
331
|
+
## Companion assets
|
|
332
|
+
|
|
333
|
+
A camelCase descriptor maps to a dotted filename and is listed in \`static assets\`:
|
|
334
|
+
|
|
335
|
+
| descriptor | file | role |
|
|
336
|
+
| --- | --- | --- |
|
|
337
|
+
| \`css\` | \`name.css\` | document-level tokens under \`:root\` |
|
|
338
|
+
| \`targetCss\` | \`name.target.css\` | styles scoped to the host tag |
|
|
339
|
+
| \`shadowCss\` | \`name.shadow.css\` | styles inside the shadow root |
|
|
340
|
+
| \`html\` | \`name.html\` | light-DOM template |
|
|
341
|
+
| \`targetHtml\` | \`name.target.html\` | template for \`target.innerHTML\`, \`{key}\` tokens filled by \`parse()\` |
|
|
342
|
+
| \`shadowHtml\` | \`name.shadow.html\` | template for \`shadowRoot.innerHTML\` |
|
|
343
|
+
|
|
344
|
+
The loader fetches exactly the listed descriptors, so every entry in \`static assets\` needs its file.
|
|
345
|
+
|
|
346
|
+
## Events
|
|
347
|
+
|
|
348
|
+
Prefer a direct target with the default local dispatch and a typed payload:
|
|
349
|
+
|
|
350
|
+
\`\`\`ts
|
|
351
|
+
target.dispatchEvent(
|
|
352
|
+
new CustomEvent('${namespace}:action', { detail: { data: { id: 'x' } } })
|
|
353
|
+
);
|
|
354
|
+
\`\`\`
|
|
355
|
+
|
|
356
|
+
Bubble up or down the tree only when the receiver cannot be named.
|
|
357
|
+
|
|
358
|
+
## Verify
|
|
359
|
+
|
|
360
|
+
\`\`\`sh
|
|
361
|
+
pnpm typecheck
|
|
362
|
+
pnpm dev
|
|
363
|
+
pnpm build
|
|
364
|
+
pnpm preview
|
|
365
|
+
\`\`\`
|
|
366
|
+
|
|
367
|
+
\`mc clean\` removes \`build/\`, \`public/\` and \`coverage/\`.
|
|
368
|
+
|
|
369
|
+
## API reference
|
|
370
|
+
|
|
371
|
+
The \`microcomponents\` MCP server registered in \`opencode.jsonc\` documents every export, the event system, and the concept essays. Query it instead of guessing an API.
|
|
372
|
+
`;
|
|
373
|
+
}
|
|
374
|
+
function readme(namespace, tag) {
|
|
375
|
+
return `# ${namespace} App
|
|
376
|
+
|
|
377
|
+
A Micro Components project scaffolded by \`mc create --namespace ${namespace}\`.
|
|
378
|
+
|
|
379
|
+
## Install
|
|
380
|
+
|
|
381
|
+
\`\`\`sh
|
|
382
|
+
pnpm install
|
|
383
|
+
\`\`\`
|
|
384
|
+
|
|
385
|
+
## Dev
|
|
386
|
+
|
|
387
|
+
\`\`\`sh
|
|
388
|
+
pnpm dev
|
|
389
|
+
\`\`\`
|
|
390
|
+
|
|
391
|
+
The dev server runs \`tsc -w\` and serves \`build/\`, so a saved component reaches the
|
|
392
|
+
browser through \`autoload()\` with no bundling. Companion \`.css\`, \`.html\` and \`.json\`
|
|
393
|
+
files are mirrored into \`build/\` next to the compiled component, which is where the
|
|
394
|
+
autoloader looks for them.
|
|
395
|
+
|
|
396
|
+
## Build
|
|
397
|
+
|
|
398
|
+
\`\`\`sh
|
|
399
|
+
pnpm build
|
|
400
|
+
\`\`\`
|
|
401
|
+
|
|
402
|
+
\`public/\` is the production site: one \`bundle.js\` with every companion asset
|
|
403
|
+
inlined by \`mc bundle\`, the concatenated \`bundle.css\`, and the page shell. A
|
|
404
|
+
single script tag loads it, no autoloader needed.
|
|
405
|
+
|
|
406
|
+
The bundle is driven by \`src/bundle.json\`: \`entry\` names the barrel to bundle,
|
|
407
|
+
\`fileName\` the output, and \`outDir\` the folder.
|
|
408
|
+
|
|
409
|
+
\`\`\`sh
|
|
410
|
+
pnpm preview
|
|
411
|
+
\`\`\`
|
|
412
|
+
|
|
413
|
+
## Type-check
|
|
414
|
+
|
|
415
|
+
\`\`\`sh
|
|
416
|
+
pnpm typecheck
|
|
417
|
+
\`\`\`
|
|
418
|
+
|
|
419
|
+
\`tsconfig.json\` compiles \`src/\` into \`build/\`.
|
|
420
|
+
|
|
421
|
+
## Components
|
|
422
|
+
|
|
423
|
+
Components live in \`src/${namespace}/\`. The starter is \`<${tag}>\` (\`src/${namespace}/app.ts\`).
|
|
424
|
+
|
|
425
|
+
Add a component with:
|
|
426
|
+
|
|
427
|
+
\`\`\`sh
|
|
428
|
+
npx mc new ${namespace}-name --target-html --target-css
|
|
429
|
+
\`\`\`
|
|
430
|
+
|
|
431
|
+
Then add its import to \`src/${namespace}/bundle.ts\` so the production bundle picks it up.
|
|
432
|
+
|
|
433
|
+
\`.opencode/skills/components/SKILL.md\` carries the same rules for opencode: the
|
|
434
|
+
\`src/\` → \`build/\` → \`public/\` chain, the asset descriptors, the bundle registration,
|
|
435
|
+
and the event dispatch preference.
|
|
436
|
+
`;
|
|
437
|
+
}
|
|
438
|
+
function prodShell(tag) {
|
|
439
|
+
return `<!doctype html>
|
|
440
|
+
<html lang="en">
|
|
441
|
+
<head>
|
|
442
|
+
<meta charset="UTF-8" />
|
|
443
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
444
|
+
<title>${tag.replace("-app", " App")}</title>
|
|
445
|
+
<script type="module" src="bundle.js"><\/script>
|
|
446
|
+
<link rel="stylesheet" href="./bundle.css" />
|
|
447
|
+
</head>
|
|
448
|
+
<body>
|
|
449
|
+
<${tag}></${tag}>
|
|
450
|
+
</body>
|
|
451
|
+
</html>
|
|
452
|
+
`;
|
|
453
|
+
}
|
|
454
|
+
function devShell(namespace, tag) {
|
|
455
|
+
return `<!doctype html>
|
|
456
|
+
<html lang="en">
|
|
457
|
+
<head>
|
|
458
|
+
<meta charset="UTF-8" />
|
|
459
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
460
|
+
<title>${namespace} App</title>
|
|
461
|
+
<script type="importmap">
|
|
462
|
+
{ "imports": { "@twobirds/microcomponents": "/microcomponents.min.js" } }
|
|
463
|
+
<\/script>
|
|
464
|
+
<script type="module">
|
|
465
|
+
import * as m from '@twobirds/microcomponents';
|
|
466
|
+
m.autoload(true);
|
|
467
|
+
Object.assign(window, m);
|
|
468
|
+
<\/script>
|
|
469
|
+
</head>
|
|
470
|
+
<body>
|
|
471
|
+
<${tag}></${tag}>
|
|
472
|
+
</body>
|
|
473
|
+
</html>
|
|
474
|
+
`;
|
|
475
|
+
}
|
|
476
|
+
function bundle() {
|
|
477
|
+
return `// Barrel entry for the production bundle: imports every component so the bundler
|
|
478
|
+
// registers them all in one file. Add a line here when a component is created.
|
|
479
|
+
|
|
480
|
+
import './app.js';
|
|
481
|
+
`;
|
|
482
|
+
}
|
|
483
|
+
function bundleConfig(namespace) {
|
|
484
|
+
return JSON.stringify({
|
|
485
|
+
entry: `${namespace}/bundle.ts`,
|
|
486
|
+
fileName: "bundle.js",
|
|
487
|
+
outDir: "public"
|
|
488
|
+
}, null, " ");
|
|
489
|
+
}
|
|
490
|
+
function appSource(namespace, tag) {
|
|
491
|
+
const className = kebabToPascal(tag);
|
|
492
|
+
return `import { DC, defineCE, parse, processAssets, type McEvent } from '@twobirds/microcomponents';
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
\`${tag}\` — the starter micro component.
|
|
496
|
+
|
|
497
|
+
@example
|
|
498
|
+
\`\`\`html
|
|
499
|
+
<${tag}></${tag}>
|
|
500
|
+
\`\`\`
|
|
501
|
+
*/
|
|
502
|
+
class ${className} extends DC {
|
|
503
|
+
static assets = ['targetHtml', 'targetCss'];
|
|
504
|
+
|
|
505
|
+
data = { title: 'Micro Components' };
|
|
506
|
+
|
|
507
|
+
// Fill the template \`{key}\` tokens once, instead of binding reactive state.
|
|
508
|
+
oneAssets(ev: McEvent<Record<string, string>>): void {
|
|
509
|
+
processAssets(this, {
|
|
510
|
+
targetHtml: parse(ev.detail.data.targetHtml, this.data),
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
defineCE('${tag}', ${className});
|
|
516
|
+
`;
|
|
517
|
+
}
|
|
518
|
+
function appHtml(namespace) {
|
|
519
|
+
return `<div class="app">
|
|
520
|
+
<h1>{title}</h1>
|
|
521
|
+
<p>Namespace scaffold ready. Edit <code>src/${namespace}/app.ts</code>.</p>
|
|
522
|
+
</div>
|
|
523
|
+
`;
|
|
524
|
+
}
|
|
525
|
+
function appCss(tag) {
|
|
526
|
+
return `${tag} {
|
|
527
|
+
display: block;
|
|
528
|
+
padding: 2rem;
|
|
529
|
+
font-family: system-ui, sans-serif;
|
|
530
|
+
}
|
|
531
|
+
`;
|
|
532
|
+
}
|
|
533
|
+
//#endregion
|
|
534
|
+
//#region src/cli/writeScaffold.ts
|
|
535
|
+
/**
|
|
536
|
+
Filesystem side of {@link scaffoldComponent} and {@link scaffoldProject}: writes the
|
|
537
|
+
generated files under a root directory, creating nested folders as needed.
|
|
538
|
+
|
|
539
|
+
Existing files are never overwritten silently: every target is checked before the
|
|
540
|
+
first write, so a rerun in a populated directory reports the conflicts and touches
|
|
541
|
+
nothing. `force` opts into overwriting.
|
|
542
|
+
|
|
543
|
+
@module CLI/Scaffold
|
|
544
|
+
*/
|
|
545
|
+
/**
|
|
546
|
+
The path of a target file as it appears from the current working directory.
|
|
547
|
+
`mc create` writes a project into a folder of its own, so a path relative to the
|
|
548
|
+
project root cannot be found from the directory the command ran in: this turns
|
|
549
|
+
`src/mc/app.ts` under `mc-app/` into `mc-app/src/mc/app.ts`. Falls back to the
|
|
550
|
+
absolute path when the target lies outside the current directory, so the log never
|
|
551
|
+
prints `../../..` chains.
|
|
552
|
+
|
|
553
|
+
@example
|
|
554
|
+
```ts
|
|
555
|
+
// in a directory containing mc-app/src/mc/app.ts
|
|
556
|
+
displayPath(path.resolve('mc-app', 'src/mc/app.ts'));
|
|
557
|
+
displayPath('/tmp/mc-app/package.json');
|
|
558
|
+
```
|
|
559
|
+
@param abs - Absolute path of the target file.
|
|
560
|
+
@group CLI
|
|
561
|
+
*/
|
|
562
|
+
function displayPath(abs) {
|
|
563
|
+
const rel = path.relative(process.cwd(), abs);
|
|
564
|
+
return rel.startsWith("..") ? abs : rel;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
Writes generated files under `root`, creating nested folders as needed, and reports
|
|
568
|
+
the written paths relative to `root`.
|
|
569
|
+
|
|
570
|
+
@example
|
|
571
|
+
```ts
|
|
572
|
+
const written = await writeGenerated({ 'my/card.ts': 'source' }, 'src', false);
|
|
573
|
+
console.log(written);
|
|
574
|
+
```
|
|
575
|
+
@param files - Files to write, keyed by path relative to `root`.
|
|
576
|
+
@param root - Directory to write under (`src` for a component, `mc-app` for a project).
|
|
577
|
+
@param force - Overwrite files that already exist.
|
|
578
|
+
@returns The written file paths, relative to `root`.
|
|
579
|
+
@throws If any target file exists and `force` is false. Nothing is written in that case.
|
|
580
|
+
*/
|
|
581
|
+
async function writeGenerated(files, root, force) {
|
|
582
|
+
const targets = Object.entries(files).map(([rel, contents]) => ({
|
|
583
|
+
rel,
|
|
584
|
+
abs: path.resolve(root, rel),
|
|
585
|
+
contents
|
|
586
|
+
}));
|
|
587
|
+
if (!force) {
|
|
588
|
+
const conflicts = targets.filter((target) => existsSync(target.abs)).map((t) => displayPath(t.abs));
|
|
589
|
+
if (conflicts.length) throw new Error(`refusing to overwrite ${conflicts.length} existing file(s):\n${conflicts.map((file) => `\t${file}`).join("\n")}\n\nrerun with --force to overwrite`);
|
|
590
|
+
}
|
|
591
|
+
const written = [];
|
|
592
|
+
for (const { rel, abs, contents } of targets) {
|
|
593
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
594
|
+
await writeFile(abs, contents, "utf8");
|
|
595
|
+
written.push(rel);
|
|
596
|
+
}
|
|
597
|
+
return written;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
Scaffolds a component onto disk under `root`.
|
|
601
|
+
|
|
602
|
+
@example
|
|
603
|
+
```ts
|
|
604
|
+
const written = await writeScaffold('my-card', 'src', { assets: ['css'] });
|
|
605
|
+
console.log(written);
|
|
606
|
+
```
|
|
607
|
+
@param tag - Kebab-case component tag.
|
|
608
|
+
@param root - Directory to scaffold under (e.g. `src`).
|
|
609
|
+
@param options - Which companion files to emit, and whether to overwrite existing ones.
|
|
610
|
+
@returns The written file paths, relative to `root`.
|
|
611
|
+
@throws If a target file exists and `force` is false.
|
|
612
|
+
*/
|
|
613
|
+
async function writeScaffold(tag, root, options = {}) {
|
|
614
|
+
const { files } = scaffoldComponent(tag, options);
|
|
615
|
+
return writeGenerated(files, root, options.force ?? false);
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
Filesystem side of {@link scaffoldProject}: writes a complete dev environment for a
|
|
619
|
+
namespace under a project root, creating nested folders as needed.
|
|
620
|
+
|
|
621
|
+
@example
|
|
622
|
+
```ts
|
|
623
|
+
const written = await writeProject('mc', 'mc-app');
|
|
624
|
+
console.log(written);
|
|
625
|
+
```
|
|
626
|
+
@param namespace - 1 to 4 lowercase letters.
|
|
627
|
+
@param root - Directory to create the project in (e.g. `mc-app`).
|
|
628
|
+
@param options - Whether to overwrite existing files.
|
|
629
|
+
@returns The written file paths, relative to `root`.
|
|
630
|
+
@throws If `namespace` is not 1 to 4 lowercase letters, or a target file exists and
|
|
631
|
+
`force` is false.
|
|
632
|
+
*/
|
|
633
|
+
async function writeProject(namespace, root, options = {}) {
|
|
634
|
+
const { files } = scaffoldProject(namespace);
|
|
635
|
+
return writeGenerated(files, root, options.force ?? false);
|
|
636
|
+
}
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/cli/inliner.ts
|
|
639
|
+
/**
|
|
640
|
+
Asset inlining for the vite-free CLI bundler.
|
|
641
|
+
|
|
642
|
+
Companion `.css` / `.html` files declared by a DC class through
|
|
643
|
+
`static assets = [...]` are read at build time and embedded as a
|
|
644
|
+
`static _assets = { ... }` property inserted as the first line inside the
|
|
645
|
+
class body, so `loadAssets()` never fetches them at runtime. The original
|
|
646
|
+
`static assets` line is preserved for debugging purposes.
|
|
647
|
+
|
|
648
|
+
This is the inlining the shipped Vite plugin used, ported to run inside
|
|
649
|
+
{@link bundleEntry} without Vite: the bundler calls {@link inlineAssets} on
|
|
650
|
+
each module body before wrapping it, reading the companion files from the
|
|
651
|
+
same folder as the compiled `.js` (the mirrored `build/` tree).
|
|
652
|
+
|
|
653
|
+
@module CLI/Inliner
|
|
654
|
+
*/
|
|
655
|
+
/**
|
|
656
|
+
* Escape a raw string so it can be safely embedded inside a JS template literal.
|
|
657
|
+
* Backticks and `${` sequences are escaped to prevent breaking the enclosing literal.
|
|
658
|
+
*
|
|
659
|
+
* @example Returns the input with backticks and `${` sequences escaped.
|
|
660
|
+
* ```ts
|
|
661
|
+
* escapeTemplateLiteral('price is ${cost}`done`');
|
|
662
|
+
* ```
|
|
663
|
+
*/
|
|
664
|
+
function escapeTemplateLiteral(raw) {
|
|
665
|
+
return raw.replace(/\\/g, "\\\\").replace(/\$/g, "\\$").replace(/`/g, "\\`");
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Resolve the relative file path for a given asset descriptor and component tag.
|
|
669
|
+
* Only the last segment of the tag is used as the basename since the `.js` source
|
|
670
|
+
* already lives in the correct directory (e.g. `cart-container-checkout.js` is
|
|
671
|
+
* inside `cart/container/`, so we just need `checkout.target.html`).
|
|
672
|
+
*
|
|
673
|
+
* @example Returns `./header.shadow.css`, `./card.target.html` and `./summary.css` for the calls below.
|
|
674
|
+
* ```ts
|
|
675
|
+
* resolveAssetPath('shop-header', 'shadowCss');
|
|
676
|
+
* resolveAssetPath('product-card', 'targetHtml');
|
|
677
|
+
* resolveAssetPath('cart-summary', 'css');
|
|
678
|
+
* ```
|
|
679
|
+
*/
|
|
680
|
+
function resolveAssetPath(tagName, asset) {
|
|
681
|
+
const basename = tagName.split("-").pop() ?? tagName;
|
|
682
|
+
const m = asset.match(/^([a-z]+)([A-Z][a-z]*)$/);
|
|
683
|
+
if (m) return `./${basename}.${m[1]}.${m[2].toLowerCase()}`;
|
|
684
|
+
return `./${basename}.${asset.toLowerCase()}`;
|
|
685
|
+
}
|
|
686
|
+
/**
|
|
687
|
+
* Extract the tag name from a `defineCE('tag-name', ...)` call in source code.
|
|
688
|
+
* Returns the first found tag or undefined.
|
|
689
|
+
*
|
|
690
|
+
* @example Returns `'shop-header'` for the code below.
|
|
691
|
+
* ```ts
|
|
692
|
+
* extractDefineCETag("defineCE('shop-header', ShopHeader);");
|
|
693
|
+
* ```
|
|
694
|
+
*/
|
|
695
|
+
function extractDefineCETag(code) {
|
|
696
|
+
const m = code.match(/defineCE\s*\(\s*['"]([^'"]+)['"]/);
|
|
697
|
+
return m ? m[1] : void 0;
|
|
698
|
+
}
|
|
699
|
+
/**
|
|
700
|
+
* Strip all line and block comments from source code, replacing
|
|
701
|
+
* their content with equivalent whitespace so character positions stay valid for
|
|
702
|
+
* later string slicing.
|
|
703
|
+
*
|
|
704
|
+
* @example Blanks out the `// note` segment while keeping every character
|
|
705
|
+
* position and line break, so later string slicing stays aligned.
|
|
706
|
+
* ```ts
|
|
707
|
+
* stripComments('const a = 1; // note\nconst b = 2;');
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
function stripComments(code) {
|
|
711
|
+
let result = "";
|
|
712
|
+
let i = 0;
|
|
713
|
+
while (i < code.length) if (code[i] === "/" && code[i + 1] === "/") {
|
|
714
|
+
const end = code.indexOf("\n", i);
|
|
715
|
+
if (end === -1) {
|
|
716
|
+
result += " ".repeat(code.length - i);
|
|
717
|
+
break;
|
|
718
|
+
}
|
|
719
|
+
result += " ".repeat(end - i) + "\n";
|
|
720
|
+
i = end + 1;
|
|
721
|
+
} else if (code[i] === "/" && code[i + 1] === "*") {
|
|
722
|
+
const end = code.indexOf("*/", i + 2);
|
|
723
|
+
if (end === -1) {
|
|
724
|
+
result += " ".repeat(code.length - i);
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
let j = i;
|
|
728
|
+
while (j <= end + 1) {
|
|
729
|
+
result += code[j] === "\n" ? "\n" : " ";
|
|
730
|
+
j++;
|
|
731
|
+
}
|
|
732
|
+
i = end + 2;
|
|
733
|
+
} else {
|
|
734
|
+
result += code[i];
|
|
735
|
+
i++;
|
|
736
|
+
}
|
|
737
|
+
return result;
|
|
738
|
+
}
|
|
739
|
+
/**
|
|
740
|
+
* Read and inline all assets for a given tag, returning an array of
|
|
741
|
+
* `{ key, literal }` entries suitable for building object literals.
|
|
742
|
+
* Plain `.css` files hold CSS custom-property presets and are inlined with
|
|
743
|
+
* their real content so the component injects the default variables at
|
|
744
|
+
* runtime. They are never left empty — an empty string would make the
|
|
745
|
+
* runtime treat the asset as not-yet-loaded and re-fetch it.
|
|
746
|
+
*
|
|
747
|
+
* @example Returns one escaped template literal per descriptor, ready to be joined into an object.
|
|
748
|
+
* ```ts
|
|
749
|
+
* readAssetsInline('/build/shop/header', 'shop-header', ['shadowCss', 'targetHtml']);
|
|
750
|
+
* ```
|
|
751
|
+
* @throws If a companion file for a descriptor does not exist.
|
|
752
|
+
*/
|
|
753
|
+
async function readAssetsInline(dir, tagName, assets) {
|
|
754
|
+
const entries = [];
|
|
755
|
+
for (const asset of assets) {
|
|
756
|
+
const relPath = resolveAssetPath(tagName, asset);
|
|
757
|
+
const filePath = path.resolve(dir, relPath);
|
|
758
|
+
try {
|
|
759
|
+
const content = await readFile(filePath, "utf8");
|
|
760
|
+
entries.push({
|
|
761
|
+
key: asset,
|
|
762
|
+
literal: "`" + escapeTemplateLiteral(content).replace(/[\n\t\r]/g, " ").replace(/\s\s/g, " ") + "`"
|
|
763
|
+
});
|
|
764
|
+
} catch (_err) {
|
|
765
|
+
throw new Error(`[load-assets] Cannot resolve asset "${asset}" for component "${tagName}": file not found at ${filePath}`, { cause: _err });
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
return entries;
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Inline every `static assets = [...]` declaration in a module body as a
|
|
772
|
+
* `static _assets` property, reading the companion files from `dir`.
|
|
773
|
+
*
|
|
774
|
+
* @example Returns the class with `static _assets` injected after its opening brace.
|
|
775
|
+
* ```ts
|
|
776
|
+
* await inlineAssets("class A extends DC { static assets = ['targetHtml']; }", '/build/mc');
|
|
777
|
+
* ```
|
|
778
|
+
* @param code Module source (the compiled `.js`).
|
|
779
|
+
* @param dir Folder holding the companion files next to the module.
|
|
780
|
+
* @returns The transformed code, or the original when there is nothing to inline.
|
|
781
|
+
*/
|
|
782
|
+
async function inlineAssets(code, dir) {
|
|
783
|
+
if (!code.includes("static assets")) return code;
|
|
784
|
+
const stripped = stripComments(code);
|
|
785
|
+
const tagName = extractDefineCETag(stripped);
|
|
786
|
+
if (!tagName) return code;
|
|
787
|
+
const saMatches = [...stripped.matchAll(/static\s+assets\s*=\s*\[(.*?)\]\s*;?/gs)];
|
|
788
|
+
if (saMatches.length === 0) return code;
|
|
789
|
+
let transformed = code;
|
|
790
|
+
for (let i = saMatches.length - 1; i >= 0; i--) {
|
|
791
|
+
const match = saMatches[i];
|
|
792
|
+
const assets = match[1].split(",").map((a) => a.trim().replace(/^['"]|['"]$/g, "")).filter(Boolean);
|
|
793
|
+
if (assets.length === 0) continue;
|
|
794
|
+
const assetsObj = `{ ${(await readAssetsInline(dir, tagName, assets)).map((e) => `${e.key}: ${e.literal}`).join(", ")} }`;
|
|
795
|
+
const beforeMatch = stripped.substring(0, match.index);
|
|
796
|
+
const classOpenMatch = /class\s+\w+[^{]*\{/.exec(beforeMatch);
|
|
797
|
+
if (!classOpenMatch) continue;
|
|
798
|
+
const classBraceIndex = classOpenMatch.index + classOpenMatch[0].length - 1;
|
|
799
|
+
const injectCode = `\n static _assets = ${assetsObj};\n`;
|
|
800
|
+
transformed = transformed.substring(0, classBraceIndex + 1) + injectCode + transformed.substring(classBraceIndex + 1);
|
|
801
|
+
}
|
|
802
|
+
return transformed;
|
|
803
|
+
}
|
|
804
|
+
//#endregion
|
|
805
|
+
//#region src/cli/bundler.ts
|
|
806
|
+
/**
|
|
807
|
+
Pure-TypeScript global bundler — no Vite, no Rolldown, no oxc.
|
|
808
|
+
|
|
809
|
+
Reads the `tsc`-emitted ESM files in a project's `build/` and flattens the
|
|
810
|
+
barrel entry's dependency graph into one self-contained ES module. Each source
|
|
811
|
+
module is wrapped in a function scope keyed by its path (relative to the root),
|
|
812
|
+
its `import`/`export` statements are rewritten to a tiny `require` registry,
|
|
813
|
+
and the entry's re-exported names are emitted as the bundle's public exports.
|
|
814
|
+
|
|
815
|
+
When `inline` is set, every module body is first run through
|
|
816
|
+
{@link inlineAssets}, so a DC class declaring `static assets = [...]` gets its
|
|
817
|
+
companion `.css` / `.html` embedded as `static _assets` and `loadAssets()`
|
|
818
|
+
never fetches them at runtime.
|
|
819
|
+
|
|
820
|
+
The rewrite is comment-aware: `import`/`export` lines inside JSDoc `@example`
|
|
821
|
+
blocks are left untouched, so the self-referential `@twobirds/microcomponents`
|
|
822
|
+
specifiers in documentation never break resolution.
|
|
823
|
+
|
|
824
|
+
@module CLI/Bundler
|
|
825
|
+
*/
|
|
826
|
+
/**
|
|
827
|
+
* Resolve a relative specifier to a module id (path relative to `root`),
|
|
828
|
+
* given the id of the importing module. Bare specifiers (`node:`, `@twobirds/`)
|
|
829
|
+
* are external and return `undefined`.
|
|
830
|
+
*
|
|
831
|
+
* @example Resolves a sibling and a parent-relative import.
|
|
832
|
+
* ```ts
|
|
833
|
+
* resolveId('./events.js', 'utils/helpers.js'); // 'utils/events.js'
|
|
834
|
+
* resolveId('../utils/helpers.js', 'core/MC.js'); // 'utils/helpers.js'
|
|
835
|
+
* ```
|
|
836
|
+
*/
|
|
837
|
+
function resolveId(spec, currentId) {
|
|
838
|
+
if (!spec.startsWith(".")) return void 0;
|
|
839
|
+
return path.posix.normalize(path.posix.join(path.posix.dirname(currentId), spec));
|
|
840
|
+
}
|
|
841
|
+
/** Parse the binding list inside `{ a, b as c }` into local/exported pairs. */
|
|
842
|
+
function parseBindings(inner) {
|
|
843
|
+
return inner.split(",").map((b) => b.trim()).filter(Boolean).map((b) => {
|
|
844
|
+
const m = b.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
|
|
845
|
+
if (!m) return {
|
|
846
|
+
local: b,
|
|
847
|
+
exported: b
|
|
848
|
+
};
|
|
849
|
+
return {
|
|
850
|
+
local: m[1],
|
|
851
|
+
exported: m[2] ?? m[1]
|
|
852
|
+
};
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Rewrite a single top-level `import` line into a `require` destructure.
|
|
857
|
+
* Returns the replacement line and the resolved dependency id.
|
|
858
|
+
*
|
|
859
|
+
* @example Turns a named import into a registry lookup.
|
|
860
|
+
* ```ts
|
|
861
|
+
* rewriteImport("import { on, trigger } from './events.js'", 'utils/helpers.js');
|
|
862
|
+
* ```
|
|
863
|
+
*/
|
|
864
|
+
function rewriteImport(line, currentId) {
|
|
865
|
+
const sideEffect = line.match(/^import\s*['"]([^'"]+)['"];?\s*$/);
|
|
866
|
+
if (sideEffect) {
|
|
867
|
+
const id = resolveId(sideEffect[1], currentId);
|
|
868
|
+
return {
|
|
869
|
+
line: id ? `__r(${JSON.stringify(id)});` : line,
|
|
870
|
+
spec: id,
|
|
871
|
+
names: [],
|
|
872
|
+
pairs: []
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const m = line.match(/^import\s+(?:\*\s+as\s+(\w+)|\{([^}]*)\}|(\w+))\s*from\s*['"]([^'"]+)['"];?\s*$/);
|
|
876
|
+
if (!m) return {
|
|
877
|
+
line,
|
|
878
|
+
names: [],
|
|
879
|
+
pairs: []
|
|
880
|
+
};
|
|
881
|
+
const id = resolveId(m[4], currentId);
|
|
882
|
+
if (!id) return {
|
|
883
|
+
line,
|
|
884
|
+
names: [],
|
|
885
|
+
pairs: []
|
|
886
|
+
};
|
|
887
|
+
const lookup = `__r(${JSON.stringify(id)})`;
|
|
888
|
+
if (m[1]) return {
|
|
889
|
+
line: `const ${m[1]} = ${lookup};`,
|
|
890
|
+
spec: id,
|
|
891
|
+
names: [],
|
|
892
|
+
pairs: []
|
|
893
|
+
};
|
|
894
|
+
if (m[2]) return {
|
|
895
|
+
line: `const { ${parseBindings(m[2]).map((b) => b.local === b.exported ? b.local : `${b.local}: ${b.exported}`).join(", ")} } = ${lookup};`,
|
|
896
|
+
spec: id,
|
|
897
|
+
names: [],
|
|
898
|
+
pairs: []
|
|
899
|
+
};
|
|
900
|
+
return {
|
|
901
|
+
line: `const ${m[3]} = ${lookup}.default;`,
|
|
902
|
+
spec: id,
|
|
903
|
+
names: [],
|
|
904
|
+
pairs: []
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
/**
|
|
908
|
+
* Rewrite a single top-level `export` line. `export *` / `export { } from`
|
|
909
|
+
* become registry merges; declarations and bare `export { }` record the local
|
|
910
|
+
* bindings that get assigned to `exports` at the end of the module body.
|
|
911
|
+
*
|
|
912
|
+
* @example Turns a star re-export into an Object.assign merge.
|
|
913
|
+
* ```ts
|
|
914
|
+
* rewriteExport("export * from './helpers.js'", 'microcomponents.js');
|
|
915
|
+
* ```
|
|
916
|
+
*/
|
|
917
|
+
function rewriteExport(line, currentId) {
|
|
918
|
+
const star = line.match(/^export\s+\*\s+from\s*['"]([^'"]+)['"];?\s*$/);
|
|
919
|
+
if (star) {
|
|
920
|
+
const id = resolveId(star[1], currentId);
|
|
921
|
+
return {
|
|
922
|
+
line: id ? `Object.assign(exports, ${`__r(${JSON.stringify(id)})`});` : line,
|
|
923
|
+
spec: id,
|
|
924
|
+
star: true,
|
|
925
|
+
names: [],
|
|
926
|
+
pairs: []
|
|
927
|
+
};
|
|
928
|
+
}
|
|
929
|
+
const namedFrom = line.match(/^export\s+\{([^}]*)\}\s*from\s*['"]([^'"]+)['"];?\s*$/);
|
|
930
|
+
if (namedFrom) {
|
|
931
|
+
const id = resolveId(namedFrom[2], currentId);
|
|
932
|
+
const bindings = parseBindings(namedFrom[1]);
|
|
933
|
+
const destructure = bindings.map((b) => `${b.local}: __x_${b.exported}`).join(", ");
|
|
934
|
+
const assigns = bindings.map((b) => `exports.${b.exported} = __x_${b.exported};`).join(" ");
|
|
935
|
+
return {
|
|
936
|
+
line: id ? `const { ${destructure} } = __r(${JSON.stringify(id)}); ${assigns}` : line,
|
|
937
|
+
spec: id,
|
|
938
|
+
names: bindings.map((b) => b.exported),
|
|
939
|
+
pairs: []
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
const named = line.match(/^export\s+\{([^}]*)\};?\s*$/);
|
|
943
|
+
if (named) {
|
|
944
|
+
const bindings = parseBindings(named[1]);
|
|
945
|
+
return {
|
|
946
|
+
line: "",
|
|
947
|
+
names: bindings.map((b) => b.exported),
|
|
948
|
+
pairs: bindings
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const decl = line.match(/^export\s+(function|class|const|let|var)\s+(\w+)/);
|
|
952
|
+
if (decl) return {
|
|
953
|
+
line: line.replace(/^export\s+/, ""),
|
|
954
|
+
names: [decl[2]],
|
|
955
|
+
pairs: [{
|
|
956
|
+
local: decl[2],
|
|
957
|
+
exported: decl[2]
|
|
958
|
+
}]
|
|
959
|
+
};
|
|
960
|
+
if (line.match(/^export\s+default\s+/)) return {
|
|
961
|
+
line: "const __default = " + line.replace(/^export\s+default\s+/, ""),
|
|
962
|
+
names: ["default"],
|
|
963
|
+
pairs: [{
|
|
964
|
+
local: "__default",
|
|
965
|
+
exported: "default"
|
|
966
|
+
}]
|
|
967
|
+
};
|
|
968
|
+
return {
|
|
969
|
+
line,
|
|
970
|
+
names: [],
|
|
971
|
+
pairs: []
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Depth-first, post-order walk of one entry's dependency graph. Each visited
|
|
976
|
+
* module is rewritten once; `export *` merges the dependency's export names
|
|
977
|
+
* into the current module's public set.
|
|
978
|
+
*
|
|
979
|
+
* @example Walks the whole barrel graph from the entry.
|
|
980
|
+
* ```ts
|
|
981
|
+
* const mods = await collectModules('mc/bundle.js', 'build');
|
|
982
|
+
* ```
|
|
983
|
+
*/
|
|
984
|
+
async function collectModules(entryId, root, inline) {
|
|
985
|
+
const modules = /* @__PURE__ */ new Map();
|
|
986
|
+
/** Modules currently being processed. Real cycles exist in the graph
|
|
987
|
+
* (e.g. `state/observables.js` <-> `state/placeholders.js`), so a
|
|
988
|
+
* re-entrant visit must return early instead of recursing forever. The
|
|
989
|
+
* `__r` registry mirrors ESM live-binding semantics, so the cycle resolves
|
|
990
|
+
* at runtime once both modules have run. */
|
|
991
|
+
const visiting = /* @__PURE__ */ new Set();
|
|
992
|
+
async function visit(id) {
|
|
993
|
+
if (modules.has(id) || visiting.has(id)) return;
|
|
994
|
+
visiting.add(id);
|
|
995
|
+
let code = await readFile(path.join(root, id), "utf8");
|
|
996
|
+
if (inline) code = await inlineAssets(code, path.join(root, path.dirname(id)));
|
|
997
|
+
const stripped = stripComments(code);
|
|
998
|
+
const lines = code.split("\n");
|
|
999
|
+
const strippedLines = stripped.split("\n");
|
|
1000
|
+
const exportNames = /* @__PURE__ */ new Set();
|
|
1001
|
+
const pairs = [];
|
|
1002
|
+
const out = [];
|
|
1003
|
+
const deps = [];
|
|
1004
|
+
const starSpecs = [];
|
|
1005
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1006
|
+
const t = strippedLines[i].trim();
|
|
1007
|
+
if (t.startsWith("import ")) {
|
|
1008
|
+
const r = rewriteImport(lines[i], id);
|
|
1009
|
+
out.push(r.line);
|
|
1010
|
+
if (r.spec) deps.push(r.spec);
|
|
1011
|
+
} else if (t.startsWith("export ")) {
|
|
1012
|
+
const r = rewriteExport(lines[i], id);
|
|
1013
|
+
out.push(r.line);
|
|
1014
|
+
if (r.spec) deps.push(r.spec);
|
|
1015
|
+
if (r.star && r.spec) starSpecs.push(r.spec);
|
|
1016
|
+
for (const n of r.names) exportNames.add(n);
|
|
1017
|
+
for (const p of r.pairs) pairs.push(p);
|
|
1018
|
+
} else out.push(lines[i]);
|
|
1019
|
+
}
|
|
1020
|
+
for (const d of deps) await visit(d);
|
|
1021
|
+
for (const s of starSpecs) for (const n of modules.get(s).exportNames) exportNames.add(n);
|
|
1022
|
+
visiting.delete(id);
|
|
1023
|
+
modules.set(id, {
|
|
1024
|
+
id,
|
|
1025
|
+
body: out.join("\n"),
|
|
1026
|
+
exportNames,
|
|
1027
|
+
pairs
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
await visit(entryId);
|
|
1031
|
+
return modules;
|
|
1032
|
+
}
|
|
1033
|
+
/**
|
|
1034
|
+
* Assemble one entry's flattened ES module string from its collected graph.
|
|
1035
|
+
*
|
|
1036
|
+
* @example Produces a self-contained bundle for the barrel entry.
|
|
1037
|
+
* ```ts
|
|
1038
|
+
* const code = await bundleEntry('mc/bundle.js', 'build', true);
|
|
1039
|
+
* ```
|
|
1040
|
+
* @param entryId Entry module id, relative to `root` (e.g. `mc/bundle.js`).
|
|
1041
|
+
* @param root Folder holding the compiled `.js` files.
|
|
1042
|
+
* @param inline Inline companion assets into DC classes before wrapping.
|
|
1043
|
+
*/
|
|
1044
|
+
async function bundleEntry(entryId, root, inline = true) {
|
|
1045
|
+
const modules = await collectModules(entryId, root, inline);
|
|
1046
|
+
const wrappers = [];
|
|
1047
|
+
for (const m of modules.values()) {
|
|
1048
|
+
const assigns = m.pairs.map((p) => `exports.${p.exported} = ${p.local};`).join("\n");
|
|
1049
|
+
wrappers.push(`__m[${JSON.stringify(m.id)}] = function (exports, __r) {\n${m.body}\n${assigns}\n};`);
|
|
1050
|
+
}
|
|
1051
|
+
const names = [...modules.get(entryId).exportNames];
|
|
1052
|
+
const bindings = names.map((n, i) => `const __ex${i} = __e[${JSON.stringify(n)}];`).join("\n");
|
|
1053
|
+
const publicExports = names.map((n, i) => `__ex${i} as ${n}`).join(", ");
|
|
1054
|
+
return [
|
|
1055
|
+
"const __m = {};",
|
|
1056
|
+
"const __c = {};",
|
|
1057
|
+
"function __r(id) {",
|
|
1058
|
+
" if (__c[id]) return __c[id];",
|
|
1059
|
+
" const exp = {};",
|
|
1060
|
+
" __c[id] = exp;",
|
|
1061
|
+
" __m[id](exp, __r);",
|
|
1062
|
+
" return exp;",
|
|
1063
|
+
"}",
|
|
1064
|
+
wrappers.join("\n"),
|
|
1065
|
+
`const __e = __r(${JSON.stringify(entryId)});`,
|
|
1066
|
+
bindings,
|
|
1067
|
+
`export { ${publicExports} };`
|
|
1068
|
+
].join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
//#endregion
|
|
1071
|
+
//#region src/cli/bundle.ts
|
|
1072
|
+
/**
|
|
1073
|
+
Filesystem side of `mc bundle`: assembles the production site in `public/` from
|
|
1074
|
+
the `tsc`-emitted `build/` tree, with no Vite.
|
|
1075
|
+
|
|
1076
|
+
The chain is `src/` → `build/` → `public/`. The scaffolded `build` script runs
|
|
1077
|
+
`tsc -p tsconfig.json` (so `build/` holds one `.js` per component at its
|
|
1078
|
+
autoload path), then `mc bundle` mirrors the companion assets into `build/`,
|
|
1079
|
+
walks the barrel entry named by `src/bundle.json` through {@link bundleEntry}
|
|
1080
|
+
(inlining every `static assets`), rewrites the `@twobirds/microcomponents`
|
|
1081
|
+
specifier to the self-contained `microcomponents.min.js` shipped next to the
|
|
1082
|
+
bundle, concatenates the plain `.css` token presets into `bundle.css`, and
|
|
1083
|
+
copies the production shell. The result is a complete static site: a single
|
|
1084
|
+
`bundle.js` loads every component, no autoloader needed.
|
|
1085
|
+
|
|
1086
|
+
@module CLI/Bundle
|
|
1087
|
+
*/
|
|
1088
|
+
/** Companion asset extensions copied verbatim next to their compiled `.js`. */
|
|
1089
|
+
var COMPANION_EXTENSIONS = [
|
|
1090
|
+
".css",
|
|
1091
|
+
".html",
|
|
1092
|
+
".json"
|
|
1093
|
+
];
|
|
1094
|
+
/**
|
|
1095
|
+
* Whether a file is a companion asset to mirror into `build/`.
|
|
1096
|
+
*
|
|
1097
|
+
* @example
|
|
1098
|
+
* ```ts
|
|
1099
|
+
* isCompanion('app.target.html'); // true
|
|
1100
|
+
* isCompanion('index.html'); // false
|
|
1101
|
+
* ```
|
|
1102
|
+
* @param name Basename of the file.
|
|
1103
|
+
*/
|
|
1104
|
+
function isCompanion(name) {
|
|
1105
|
+
if (name === "index.html" || name === "index-dev.html") return false;
|
|
1106
|
+
return COMPANION_EXTENSIONS.some((ext) => name.endsWith(ext));
|
|
1107
|
+
}
|
|
1108
|
+
/**
|
|
1109
|
+
* Mirror companion assets (`.css`, `.html`, `.json`) from `src/` into `build/`,
|
|
1110
|
+
* preserving the tree, so the bundler reads each asset next to its compiled `.js`.
|
|
1111
|
+
*
|
|
1112
|
+
* @example
|
|
1113
|
+
* ```ts
|
|
1114
|
+
* await mirrorCompanions('<root>/src', '<root>/build');
|
|
1115
|
+
* ```
|
|
1116
|
+
* @param srcDir Source root to walk.
|
|
1117
|
+
* @param buildDir Folder to write into.
|
|
1118
|
+
* @param rel Path of the walked directory relative to `srcDir`.
|
|
1119
|
+
*/
|
|
1120
|
+
async function mirrorCompanions(srcDir, buildDir, rel = "") {
|
|
1121
|
+
for (const entry of await readdir(path.join(srcDir, rel), { withFileTypes: true })) {
|
|
1122
|
+
const next = rel ? path.join(rel, entry.name) : entry.name;
|
|
1123
|
+
if (entry.isDirectory()) {
|
|
1124
|
+
if (entry.name === "node_modules") continue;
|
|
1125
|
+
await mirrorCompanions(srcDir, buildDir, next);
|
|
1126
|
+
} else if (isCompanion(entry.name)) {
|
|
1127
|
+
const dst = path.join(buildDir, next);
|
|
1128
|
+
await mkdir(path.dirname(dst), { recursive: true });
|
|
1129
|
+
await cp(path.join(srcDir, next), dst);
|
|
1130
|
+
}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
/**
|
|
1134
|
+
* Collect the plain `.css` token presets under `src/`, skipping the companion
|
|
1135
|
+
* `.target.css` / `.shadow.css` files, which the bundler inlines into the JS.
|
|
1136
|
+
*
|
|
1137
|
+
* @example
|
|
1138
|
+
* ```ts
|
|
1139
|
+
* const css = await collectPlainCss('<root>/src');
|
|
1140
|
+
* ```
|
|
1141
|
+
* @param srcDir Source root to walk.
|
|
1142
|
+
* @param rel Path of the walked directory relative to `srcDir`.
|
|
1143
|
+
* @param found Accumulated absolute paths.
|
|
1144
|
+
*/
|
|
1145
|
+
async function collectPlainCss(srcDir, rel = "", found = []) {
|
|
1146
|
+
for (const entry of await readdir(path.join(srcDir, rel), { withFileTypes: true })) {
|
|
1147
|
+
const next = rel ? path.join(rel, entry.name) : entry.name;
|
|
1148
|
+
if (entry.isDirectory()) {
|
|
1149
|
+
if (entry.name !== "node_modules") await collectPlainCss(srcDir, next, found);
|
|
1150
|
+
} else if (entry.name.endsWith(".css") && !/\.(target|shadow)\.css$/.test(entry.name)) found.push(path.join(srcDir, next));
|
|
1151
|
+
}
|
|
1152
|
+
return found;
|
|
1153
|
+
}
|
|
1154
|
+
/**
|
|
1155
|
+
* Rewrite the library specifier in a bundled module to the self-contained bundle
|
|
1156
|
+
* shipped next to it, so the browser can resolve `@twobirds/microcomponents`
|
|
1157
|
+
* without a bare-specifier import map.
|
|
1158
|
+
*
|
|
1159
|
+
* @example Turns the package specifier into a relative path.
|
|
1160
|
+
* ```ts
|
|
1161
|
+
* rewriteLibrarySpecifier("import { DC } from '@twobirds/microcomponents';");
|
|
1162
|
+
* ```
|
|
1163
|
+
* @param code Bundled module source.
|
|
1164
|
+
* @param target Relative specifier to point the import at.
|
|
1165
|
+
*/
|
|
1166
|
+
function rewriteLibrarySpecifier(code, target = "./microcomponents.min.js") {
|
|
1167
|
+
return code.replace(/from\s*['"]@twobirds\/microcomponents['"]/g, `from '${target}'`);
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Assemble the production site in `public/` from the compiled `build/` tree.
|
|
1171
|
+
*
|
|
1172
|
+
* @example
|
|
1173
|
+
* ```ts
|
|
1174
|
+
* await bundleProject('<root>', { minJs: '<pkg>/dist/microcomponents.min.js' });
|
|
1175
|
+
* ```
|
|
1176
|
+
* @param projectRoot Project root (the folder holding `src/`, `build/`, `public/`).
|
|
1177
|
+
* @param options.minJs Absolute path of the self-contained `microcomponents.min.js` to ship next to the bundle.
|
|
1178
|
+
* @param options.bundleEntry Optional override of the barrel entry (defaults to `src/bundle.json`).
|
|
1179
|
+
* @returns The names of the files written into `public/`.
|
|
1180
|
+
*/
|
|
1181
|
+
async function bundleProject(projectRoot, options) {
|
|
1182
|
+
const sourceRoot = path.join(projectRoot, "src");
|
|
1183
|
+
const buildRoot = path.join(projectRoot, "build");
|
|
1184
|
+
const config = JSON.parse(await readFile(path.join(sourceRoot, "bundle.json"), "utf8"));
|
|
1185
|
+
const publicRoot = path.join(projectRoot, options.outDir ?? config.outDir ?? "public");
|
|
1186
|
+
const fileName = config.fileName ?? "bundle.js";
|
|
1187
|
+
await mirrorCompanions(sourceRoot, buildRoot);
|
|
1188
|
+
const rewritten = rewriteLibrarySpecifier(await bundleEntry((options.bundleEntry ?? config.entry).replace(/\.ts$/, ".js"), buildRoot, true));
|
|
1189
|
+
await mkdir(publicRoot, { recursive: true });
|
|
1190
|
+
await writeFile(path.join(publicRoot, fileName), rewritten, "utf8");
|
|
1191
|
+
await cp(options.minJs, path.join(publicRoot, "microcomponents.min.js"));
|
|
1192
|
+
const cssFiles = await collectPlainCss(sourceRoot);
|
|
1193
|
+
const parts = await Promise.all(cssFiles.map((f) => readFile(f, "utf8")));
|
|
1194
|
+
await writeFile(path.join(publicRoot, "bundle.css"), parts.join("\n"), "utf8");
|
|
1195
|
+
await cp(path.join(sourceRoot, "index.html"), path.join(publicRoot, "index.html"));
|
|
1196
|
+
return [
|
|
1197
|
+
fileName,
|
|
1198
|
+
"microcomponents.min.js",
|
|
1199
|
+
"bundle.css",
|
|
1200
|
+
"index.html"
|
|
1201
|
+
];
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* CLI entry for `mc bundle`: runs {@link bundleProject} against the current
|
|
1205
|
+
* project, reading the self-contained library from this package's own `dist/`.
|
|
1206
|
+
*
|
|
1207
|
+
* @param projectRoot Project root (defaults to the current directory).
|
|
1208
|
+
* @param minJs Path of `microcomponents.min.js` (defaults to this package's `dist/`).
|
|
1209
|
+
* @returns The process exit code.
|
|
1210
|
+
*/
|
|
1211
|
+
async function runBundle(projectRoot = process.cwd(), minJs = path.join(import.meta.dirname, "..", "microcomponents.min.js")) {
|
|
1212
|
+
const written = await bundleProject(projectRoot, { minJs });
|
|
1213
|
+
console.log(`bundled ${written.join(", ")} into public/`);
|
|
1214
|
+
return 0;
|
|
1215
|
+
}
|
|
1216
|
+
//#endregion
|
|
1217
|
+
//#region src/cli/dev.ts
|
|
1218
|
+
/**
|
|
1219
|
+
Vite-free dev server for `mc dev`.
|
|
1220
|
+
|
|
1221
|
+
A minimal `node:http` static server rooted at the project's `build/` folder, so
|
|
1222
|
+
a saved component reaches the browser through `autoload()` with no bundling. It
|
|
1223
|
+
mirrors companion assets from `src/` into `build/` (on startup and on every
|
|
1224
|
+
change), runs `tsc -w` for the lifetime of the server, serves companion
|
|
1225
|
+
`.css` / `.html` / `.json` as raw text (so `loadAssets()` receives markup it
|
|
1226
|
+
can use), redirects `/` to the autoload shell (`index-dev.html`), and serves
|
|
1227
|
+
the self-contained library at `/microcomponents.min.js` — the target of the
|
|
1228
|
+
import map in the dev shell.
|
|
1229
|
+
|
|
1230
|
+
There is no HMR: a page reload picks up the recompiled component.
|
|
1231
|
+
|
|
1232
|
+
@module CLI/Dev
|
|
1233
|
+
*/
|
|
1234
|
+
/** Page shells Vite/the server serves as HTML; everything else stays raw text. */
|
|
1235
|
+
var SHELLS = /* @__PURE__ */ new Set(["index.html", "index-dev.html"]);
|
|
1236
|
+
/**
|
|
1237
|
+
* Content type for a served file. Companion `.html` / `.json` stay raw text so
|
|
1238
|
+
* `loadAssets()` gets usable markup; the page shells are HTML.
|
|
1239
|
+
*
|
|
1240
|
+
* @example
|
|
1241
|
+
* ```ts
|
|
1242
|
+
* mimeFor('app.target.html'); // 'text/plain'
|
|
1243
|
+
* mimeFor('index-dev.html'); // 'text/html'
|
|
1244
|
+
* mimeFor('app.js'); // 'text/javascript'
|
|
1245
|
+
* ```
|
|
1246
|
+
* @param name Basename of the file.
|
|
1247
|
+
*/
|
|
1248
|
+
function mimeFor(name) {
|
|
1249
|
+
if (name.endsWith(".js")) return "text/javascript";
|
|
1250
|
+
if (name.endsWith(".css")) return "text/css";
|
|
1251
|
+
if (name.endsWith(".svg")) return "image/svg+xml";
|
|
1252
|
+
if (name.endsWith(".html")) return SHELLS.has(name) ? "text/html" : "text/plain";
|
|
1253
|
+
if (name.endsWith(".json")) return "text/plain";
|
|
1254
|
+
return "application/octet-stream";
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Resolve a request URL to a file to serve, given the served `build/` root and
|
|
1258
|
+
* the library bundle. `/` and `/index.html` map to the autoload shell;
|
|
1259
|
+
* `/microcomponents.min.js` maps to the library; everything else to `build/`.
|
|
1260
|
+
*
|
|
1261
|
+
* @example
|
|
1262
|
+
* ```ts
|
|
1263
|
+
* resolveTarget('/mc/app.target.html', buildRoot, minJs);
|
|
1264
|
+
* resolveTarget('/', buildRoot, minJs);
|
|
1265
|
+
* ```
|
|
1266
|
+
* @param url Request URL (path only, no query).
|
|
1267
|
+
* @param buildRoot Served folder.
|
|
1268
|
+
* @param minJs Absolute path of the self-contained library.
|
|
1269
|
+
* @returns Absolute file path, or `undefined` when nothing matches.
|
|
1270
|
+
*/
|
|
1271
|
+
function resolveTarget(url, buildRoot, minJs) {
|
|
1272
|
+
const file = decodeURIComponent(url.split("?")[0]);
|
|
1273
|
+
if (url === "/" || url === "/index.html") return path.join(buildRoot, "index-dev.html");
|
|
1274
|
+
if (url === "/microcomponents.min.js") return minJs;
|
|
1275
|
+
const abs = path.join(buildRoot, file);
|
|
1276
|
+
return abs.startsWith(buildRoot) && existsSync(abs) ? abs : void 0;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Serve one request from the `build/` tree.
|
|
1280
|
+
*/
|
|
1281
|
+
async function serve(req, res, buildRoot, minJs) {
|
|
1282
|
+
const abs = resolveTarget(req.url?.split("?")[0] ?? "/", buildRoot, minJs);
|
|
1283
|
+
if (!abs) {
|
|
1284
|
+
res.statusCode = 404;
|
|
1285
|
+
res.end("not found");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
const text = await readFile(abs, "utf-8");
|
|
1290
|
+
res.setHeader("Content-Type", mimeFor(path.basename(abs)));
|
|
1291
|
+
res.end(text);
|
|
1292
|
+
} catch {
|
|
1293
|
+
/* v8 ignore start — resolveTarget already checked existence; a read failure is a race */
|
|
1294
|
+
res.statusCode = 404;
|
|
1295
|
+
res.end("not found");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
/**
|
|
1299
|
+
* Mirror companion assets from `src/` into `build/`, preserving the tree, so the
|
|
1300
|
+
* served folder stays autoload-ready.
|
|
1301
|
+
*
|
|
1302
|
+
* @example
|
|
1303
|
+
* ```ts
|
|
1304
|
+
* await mirrorInto(sourceRoot, buildRoot);
|
|
1305
|
+
* ```
|
|
1306
|
+
* @param sourceRoot Source root to walk.
|
|
1307
|
+
* @param buildRoot Folder to write into.
|
|
1308
|
+
* @param rel Path of the walked directory relative to `sourceRoot`.
|
|
1309
|
+
*/
|
|
1310
|
+
async function mirrorInto(sourceRoot, buildRoot, rel = "") {
|
|
1311
|
+
for (const entry of await readdir(path.join(sourceRoot, rel), { withFileTypes: true })) {
|
|
1312
|
+
const next = rel ? path.join(rel, entry.name) : entry.name;
|
|
1313
|
+
if (entry.isDirectory()) {
|
|
1314
|
+
if (entry.name !== "node_modules") await mirrorInto(sourceRoot, buildRoot, next);
|
|
1315
|
+
} else if (isCompanion(entry.name) || entry.name === "index-dev.html") {
|
|
1316
|
+
const dst = path.join(buildRoot, next);
|
|
1317
|
+
await mkdir(path.dirname(dst), { recursive: true });
|
|
1318
|
+
await cp(path.join(sourceRoot, next), dst);
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
/**
|
|
1323
|
+
* Build the dev server for a project: an `node:http` server rooted at `build/`.
|
|
1324
|
+
*
|
|
1325
|
+
* @example
|
|
1326
|
+
* ```ts
|
|
1327
|
+
* const server = createDevServer('<root>', { minJs: '<pkg>/dist/microcomponents.min.js', port: 5173 });
|
|
1328
|
+
* server.listen(port);
|
|
1329
|
+
* ```
|
|
1330
|
+
* @param projectRoot Project root (the folder holding `src/`, `build/`).
|
|
1331
|
+
* @param options.minJs Absolute path of the self-contained library.
|
|
1332
|
+
* @param options.port Dev-server port.
|
|
1333
|
+
* @returns The HTTP server (not yet listening).
|
|
1334
|
+
*/
|
|
1335
|
+
function createDevServer(projectRoot, options) {
|
|
1336
|
+
const buildRoot = path.join(projectRoot, "build");
|
|
1337
|
+
return createServer((req, res) => {
|
|
1338
|
+
serve(req, res, buildRoot, options.minJs);
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/**
|
|
1342
|
+
* CLI entry for `mc dev`: mirror companions, start the server, and run `tsc -w`
|
|
1343
|
+
* for the lifetime of the process.
|
|
1344
|
+
*
|
|
1345
|
+
* @param projectRoot Project root (defaults to the current directory).
|
|
1346
|
+
* @param minJs Path of `microcomponents.min.js` (defaults to this package's `dist/`).
|
|
1347
|
+
* @param port Dev-server port.
|
|
1348
|
+
* @returns The process exit code (the server keeps running until killed).
|
|
1349
|
+
*/
|
|
1350
|
+
/* v8 ignore start — the long-running server + tsc watch is exercised by hand, not in the suite */
|
|
1351
|
+
async function runDev(projectRoot = process.cwd(), minJs = path.join(import.meta.dirname, "..", "microcomponents.min.js"), port = 5173) {
|
|
1352
|
+
const sourceRoot = path.join(projectRoot, "src");
|
|
1353
|
+
const buildRoot = path.join(projectRoot, "build");
|
|
1354
|
+
await mirrorInto(sourceRoot, buildRoot);
|
|
1355
|
+
const tsc = spawn("npx", [
|
|
1356
|
+
"tsc",
|
|
1357
|
+
"-w",
|
|
1358
|
+
"-p",
|
|
1359
|
+
"tsconfig.json"
|
|
1360
|
+
], {
|
|
1361
|
+
cwd: projectRoot,
|
|
1362
|
+
stdio: "inherit"
|
|
1363
|
+
});
|
|
1364
|
+
watch(sourceRoot, { recursive: true }, (_event, file) => {
|
|
1365
|
+
if (!file || file.endsWith(".ts")) return;
|
|
1366
|
+
const abs = path.join(sourceRoot, file);
|
|
1367
|
+
if (isCompanion(path.basename(file)) || file.endsWith("index-dev.html")) {
|
|
1368
|
+
const dst = path.join(buildRoot, file);
|
|
1369
|
+
mkdir(path.dirname(dst), { recursive: true }).then(() => cp(abs, dst));
|
|
1370
|
+
}
|
|
1371
|
+
});
|
|
1372
|
+
const server = createDevServer(projectRoot, {
|
|
1373
|
+
minJs,
|
|
1374
|
+
port
|
|
1375
|
+
});
|
|
1376
|
+
server.listen(port);
|
|
1377
|
+
console.log(`dev server on http://localhost:${port}/ (serving build/, tsc -w running)`);
|
|
1378
|
+
return new Promise((resolve) => {
|
|
1379
|
+
tsc.on("exit", () => {
|
|
1380
|
+
server.close();
|
|
1381
|
+
resolve(0);
|
|
1382
|
+
});
|
|
1383
|
+
});
|
|
1384
|
+
}
|
|
1385
|
+
/* v8 ignore end */
|
|
1386
|
+
//#endregion
|
|
1387
|
+
//#region src/cli/preview.ts
|
|
1388
|
+
/**
|
|
1389
|
+
Vite-free preview server for `mc preview`.
|
|
1390
|
+
|
|
1391
|
+
A minimal `node:http` static server rooted at the project's `public/` folder —
|
|
1392
|
+
the production site assembled by `mc bundle` (one `bundle.js`, the
|
|
1393
|
+
`microcomponents.min.js` it imports, `bundle.css`, and the page shell). `/`
|
|
1394
|
+
serves `index.html`; every file is served raw with its content type.
|
|
1395
|
+
|
|
1396
|
+
@module CLI/Preview
|
|
1397
|
+
*/
|
|
1398
|
+
/**
|
|
1399
|
+
* Resolve a request URL to a file in `public/`. `/` and `/index.html` map to the
|
|
1400
|
+
* production shell; everything else to `public/<path>`.
|
|
1401
|
+
*
|
|
1402
|
+
* @example
|
|
1403
|
+
* ```ts
|
|
1404
|
+
* resolvePublicTarget('/bundle.js', publicRoot);
|
|
1405
|
+
* resolvePublicTarget('/', publicRoot);
|
|
1406
|
+
* ```
|
|
1407
|
+
* @param url Request URL (path only, no query).
|
|
1408
|
+
* @param publicRoot Served folder.
|
|
1409
|
+
* @returns Absolute file path, or `undefined` when nothing matches.
|
|
1410
|
+
*/
|
|
1411
|
+
function resolvePublicTarget(url, publicRoot) {
|
|
1412
|
+
const file = decodeURIComponent(url.split("?")[0]);
|
|
1413
|
+
if (url === "/" || url === "/index.html") return path.join(publicRoot, "index.html");
|
|
1414
|
+
const abs = path.join(publicRoot, file);
|
|
1415
|
+
return abs.startsWith(publicRoot) && existsSync(abs) ? abs : void 0;
|
|
1416
|
+
}
|
|
1417
|
+
/**
|
|
1418
|
+
* Serve one request from the `public/` tree.
|
|
1419
|
+
*/
|
|
1420
|
+
async function servePublic(req, res, publicRoot) {
|
|
1421
|
+
const abs = resolvePublicTarget(req.url?.split("?")[0] ?? "/", publicRoot);
|
|
1422
|
+
if (!abs) {
|
|
1423
|
+
res.statusCode = 404;
|
|
1424
|
+
res.end("not found");
|
|
1425
|
+
return;
|
|
1426
|
+
}
|
|
1427
|
+
try {
|
|
1428
|
+
const text = await readFile(abs, "utf-8");
|
|
1429
|
+
res.setHeader("Content-Type", mimeFor(path.basename(abs)));
|
|
1430
|
+
res.end(text);
|
|
1431
|
+
} catch {
|
|
1432
|
+
/* v8 ignore start — resolvePublicTarget already checked existence; a read failure is a race */
|
|
1433
|
+
res.statusCode = 404;
|
|
1434
|
+
res.end("not found");
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* Build the preview server for a project: an `node:http` server rooted at `public/`.
|
|
1439
|
+
*
|
|
1440
|
+
* @example
|
|
1441
|
+
* ```ts
|
|
1442
|
+
* const server = createPreviewServer('<root>', 5174);
|
|
1443
|
+
* server.listen(5174);
|
|
1444
|
+
* ```
|
|
1445
|
+
* @param projectRoot Project root (the folder holding `public/`).
|
|
1446
|
+
* @param port Preview-server port.
|
|
1447
|
+
* @param outDir Output folder relative to the root (default `public`).
|
|
1448
|
+
* @returns The HTTP server (not yet listening).
|
|
1449
|
+
*/
|
|
1450
|
+
function createPreviewServer(projectRoot, port, outDir = "public") {
|
|
1451
|
+
const publicRoot = path.join(projectRoot, outDir);
|
|
1452
|
+
return createServer((req, res) => {
|
|
1453
|
+
servePublic(req, res, publicRoot);
|
|
1454
|
+
});
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* CLI entry for `mc preview`: serve the production site in `public/`.
|
|
1458
|
+
*
|
|
1459
|
+
* @param projectRoot Project root (defaults to the current directory).
|
|
1460
|
+
* @param port Preview-server port.
|
|
1461
|
+
* @returns The process exit code (the server keeps running until killed).
|
|
1462
|
+
*/
|
|
1463
|
+
/* v8 ignore start — the long-running server is exercised by hand, not in the suite */
|
|
1464
|
+
async function runPreview(projectRoot = process.cwd(), port = 5174) {
|
|
1465
|
+
const server = createPreviewServer(projectRoot, port);
|
|
1466
|
+
server.listen(port);
|
|
1467
|
+
console.log(`preview server on http://localhost:${port}/ (serving public/)`);
|
|
1468
|
+
return new Promise((resolve) => {
|
|
1469
|
+
server.on("close", () => resolve(0));
|
|
1470
|
+
});
|
|
1471
|
+
}
|
|
1472
|
+
/* v8 ignore end */
|
|
1473
|
+
//#endregion
|
|
1474
|
+
//#region src/cli/dispatch.ts
|
|
1475
|
+
/**
|
|
1476
|
+
Command dispatcher for the `mc` CLI.
|
|
1477
|
+
|
|
1478
|
+
Kept free of any top-level side effects so the routing can be unit-tested:
|
|
1479
|
+
`index.ts` is the thin executable entry that calls {@link main}, while this
|
|
1480
|
+
module only exports the logic. Commands that wrap a package.json script
|
|
1481
|
+
(`clean`, `check`) are deliberately thin — they delegate to the script so the
|
|
1482
|
+
script stays the single source of truth. When the current project defines no
|
|
1483
|
+
such script, the CLI runs its own implementation against the current project,
|
|
1484
|
+
so the command works in any consumer project.
|
|
1485
|
+
|
|
1486
|
+
@module CLI/Dispatch
|
|
1487
|
+
*/
|
|
1488
|
+
var USAGE = `mc — Micro Components build tools
|
|
1489
|
+
|
|
1490
|
+
Usage:
|
|
1491
|
+
mc new <tag> [--css] [--html] [--target-html] [--shadow-html] [--target-css] [--shadow-css] [--js] [--dir <path>] [--force] [--dry-run]
|
|
1492
|
+
mc create --namespace <ns> [--dir <path>] [--force] [--dry-run]
|
|
1493
|
+
mc dev [--port <n>]
|
|
1494
|
+
mc bundle
|
|
1495
|
+
mc preview [--port <n>]
|
|
1496
|
+
mc clean
|
|
1497
|
+
mc check
|
|
1498
|
+
mc --version
|
|
1499
|
+
mc --help
|
|
1500
|
+
|
|
1501
|
+
Commands:
|
|
1502
|
+
new <tag> Scaffold a micro component (e.g. mc new my-card --target-html --target-css)
|
|
1503
|
+
create Create a dev environment (e.g. mc create --namespace mc --dir mc-app)
|
|
1504
|
+
dev Serve build/ through autoload() with tsc -w (no bundling)
|
|
1505
|
+
bundle Assemble public/ from build/: bundle.js with assets inlined, bundle.css, shell
|
|
1506
|
+
preview Serve the production site in public/
|
|
1507
|
+
clean Remove build artifacts (dist, public, build, coverage)
|
|
1508
|
+
check Type-check every project (lib, cli, test, demo, node)
|
|
1509
|
+
|
|
1510
|
+
Companion assets:
|
|
1511
|
+
--css document-level stylesheet (CSS custom properties / design tokens)
|
|
1512
|
+
--html unvariant light-DOM template
|
|
1513
|
+
--target-html template applied to target.innerHTML
|
|
1514
|
+
--shadow-html template applied to shadowRoot.innerHTML
|
|
1515
|
+
--target-css document-level styles scoped to the host tag
|
|
1516
|
+
--shadow-css styles injected into the shadow root
|
|
1517
|
+
|
|
1518
|
+
Other options:
|
|
1519
|
+
--js emit plain JavaScript instead of TypeScript
|
|
1520
|
+
--dir <path> scaffold root (default: src)
|
|
1521
|
+
--port <n> dev / preview server port (default: 5173 / 5174)
|
|
1522
|
+
--force overwrite files that already exist
|
|
1523
|
+
--dry-run print what would be written; touch nothing on disk
|
|
1524
|
+
--version print the version of the package the CLI ships in (-v)
|
|
1525
|
+
--help print this text (-h)
|
|
1526
|
+
|
|
1527
|
+
Created paths are printed as they appear from the current directory. mc create writes
|
|
1528
|
+
a whole project into a folder of its own (default: the namespace), so its files show
|
|
1529
|
+
up under that folder, e.g. mc-app/src/mc/app.ts.
|
|
1530
|
+
`;
|
|
1531
|
+
var ASSET_FLAGS = {
|
|
1532
|
+
css: "css",
|
|
1533
|
+
html: "html",
|
|
1534
|
+
"target-html": "targetHtml",
|
|
1535
|
+
"shadow-html": "shadowHtml",
|
|
1536
|
+
"target-css": "targetCss",
|
|
1537
|
+
"shadow-css": "shadowCss"
|
|
1538
|
+
};
|
|
1539
|
+
/**
|
|
1540
|
+
A scaffolded file as it appears from the current directory: the file `rel` under
|
|
1541
|
+
`root`, resolved and made readable. `mc create` writes a project into a folder of
|
|
1542
|
+
its own, so `src/mc/app.ts` under `mc-app` prints as `mc-app/src/mc/app.ts` — the
|
|
1543
|
+
path the user can find with `ls`.
|
|
1544
|
+
|
|
1545
|
+
@example
|
|
1546
|
+
```ts
|
|
1547
|
+
createdPath('mc-app', 'src/mc/app.ts');
|
|
1548
|
+
createdPath('src', 'my/card.ts');
|
|
1549
|
+
```
|
|
1550
|
+
@param root - Scaffold root the file is written under.
|
|
1551
|
+
@param rel - Path of the file relative to `root`.
|
|
1552
|
+
*/
|
|
1553
|
+
function createdPath(root, rel) {
|
|
1554
|
+
return displayPath(path.resolve(root, rel));
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
Spawns a command and resolves with its exit code.
|
|
1558
|
+
|
|
1559
|
+
@example
|
|
1560
|
+
```ts
|
|
1561
|
+
const code = await spawnProcess('pnpm', ['run', 'clean']);
|
|
1562
|
+
```
|
|
1563
|
+
@param command - Executable to spawn.
|
|
1564
|
+
@param args - Arguments to pass.
|
|
1565
|
+
@param cwd - Working directory (defaults to the current one).
|
|
1566
|
+
*/
|
|
1567
|
+
function spawnProcess(command, args, cwd) {
|
|
1568
|
+
return new Promise((resolve) => {
|
|
1569
|
+
spawn(command, args, {
|
|
1570
|
+
stdio: "inherit",
|
|
1571
|
+
cwd
|
|
1572
|
+
}).on("exit", (code) => resolve(code ?? 1));
|
|
1573
|
+
});
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
Whether the package.json in the current directory declares `script` in its
|
|
1577
|
+
"scripts". Consumers get this check so `mc clean`/`mc check` can fall back to
|
|
1578
|
+
the CLI's own implementation when the project defines no such script.
|
|
1579
|
+
|
|
1580
|
+
@example
|
|
1581
|
+
```ts
|
|
1582
|
+
const ok = await hasScript('clean');
|
|
1583
|
+
```
|
|
1584
|
+
@param script - Script name to look up.
|
|
1585
|
+
*/
|
|
1586
|
+
async function hasScript(script) {
|
|
1587
|
+
try {
|
|
1588
|
+
return typeof JSON.parse(await readFile(path.join(process.cwd(), "package.json"), "utf8"))?.scripts?.[script] === "string";
|
|
1589
|
+
} catch {
|
|
1590
|
+
return false;
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
/** Build artifacts to remove, relative to the project root. */
|
|
1594
|
+
var CLEAN_DIRS = [
|
|
1595
|
+
"dist",
|
|
1596
|
+
"public",
|
|
1597
|
+
"build",
|
|
1598
|
+
"coverage"
|
|
1599
|
+
];
|
|
1600
|
+
/**
|
|
1601
|
+
Removes build artifacts in the current project.
|
|
1602
|
+
|
|
1603
|
+
@example
|
|
1604
|
+
```ts
|
|
1605
|
+
const code = await cleanSelf();
|
|
1606
|
+
```
|
|
1607
|
+
@param cwd - Project root (defaults to the current directory).
|
|
1608
|
+
*/
|
|
1609
|
+
async function cleanSelf(cwd = process.cwd()) {
|
|
1610
|
+
await Promise.all(CLEAN_DIRS.map(async (dir) => {
|
|
1611
|
+
const abs = path.join(cwd, dir);
|
|
1612
|
+
if (existsSync(abs)) {
|
|
1613
|
+
await rm(abs, {
|
|
1614
|
+
recursive: true,
|
|
1615
|
+
force: true
|
|
1616
|
+
});
|
|
1617
|
+
console.log(`removed ${dir}/`);
|
|
1618
|
+
}
|
|
1619
|
+
}));
|
|
1620
|
+
return 0;
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
Type-checks the current project with its own `tsconfig.json`.
|
|
1624
|
+
|
|
1625
|
+
@example
|
|
1626
|
+
```ts
|
|
1627
|
+
const code = await checkSelf();
|
|
1628
|
+
```
|
|
1629
|
+
@param cwd - Project root (defaults to the current directory).
|
|
1630
|
+
*/
|
|
1631
|
+
async function checkSelf(cwd = process.cwd()) {
|
|
1632
|
+
if (!existsSync(path.join(cwd, "tsconfig.json"))) {
|
|
1633
|
+
console.error("mc check: no tsconfig.json in the current project");
|
|
1634
|
+
return 1;
|
|
1635
|
+
}
|
|
1636
|
+
return spawnProcess("pnpm", [
|
|
1637
|
+
"exec",
|
|
1638
|
+
"tsc",
|
|
1639
|
+
"--noEmit",
|
|
1640
|
+
"-p",
|
|
1641
|
+
"tsconfig.json"
|
|
1642
|
+
], cwd);
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
The version of the package the CLI ships in, read from its package.json. Two
|
|
1646
|
+
levels above `dist/cli` (and above `src/cli` when tsx runs the source) is the
|
|
1647
|
+
package root, so the CLI reports the version it was installed with, not the
|
|
1648
|
+
version of the project it is run in.
|
|
1649
|
+
|
|
1650
|
+
@example
|
|
1651
|
+
```ts
|
|
1652
|
+
const version = await readVersion();
|
|
1653
|
+
```
|
|
1654
|
+
@param pkgPath - package.json to read (defaults to the package root of this CLI).
|
|
1655
|
+
*/
|
|
1656
|
+
async function readVersion(pkgPath = path.join(import.meta.dirname, "..", "..", "package.json")) {
|
|
1657
|
+
const { version } = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
1658
|
+
return version;
|
|
1659
|
+
}
|
|
1660
|
+
/**
|
|
1661
|
+
Runs a package.json script through pnpm and resolves with its exit code.
|
|
1662
|
+
|
|
1663
|
+
Thin wrappers like `clean` and `check` delegate here so the script — not the
|
|
1664
|
+
CLI — remains the single source of truth for what each command does. If the
|
|
1665
|
+
current project defines no such script, the CLI runs its own implementation
|
|
1666
|
+
against the current project, so the command works in any consumer project.
|
|
1667
|
+
|
|
1668
|
+
@example
|
|
1669
|
+
```ts
|
|
1670
|
+
const code = await runScript('check');
|
|
1671
|
+
```
|
|
1672
|
+
*/
|
|
1673
|
+
async function runScript(script, options) {
|
|
1674
|
+
if (await hasScript(script)) return spawnProcess("pnpm", ["run", script]);
|
|
1675
|
+
if (script === "clean") return cleanSelf();
|
|
1676
|
+
if (script === "check") return checkSelf();
|
|
1677
|
+
if (script === "dev") return runDev(process.cwd(), void 0, options?.port ?? 5173);
|
|
1678
|
+
if (script === "bundle") return runBundle();
|
|
1679
|
+
if (script === "preview") return runPreview(process.cwd(), options?.port ?? 5174);
|
|
1680
|
+
console.error(`mc: no "${script}" script in the current project`);
|
|
1681
|
+
return 1;
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
Parses `argv` and routes it to the matching command, returning a process exit
|
|
1685
|
+
code. `run` is injectable so the script-wrapping commands can be tested without
|
|
1686
|
+
spawning anything.
|
|
1687
|
+
|
|
1688
|
+
@example
|
|
1689
|
+
```ts
|
|
1690
|
+
const code = await main(['clean'], async (script) => {
|
|
1691
|
+
console.log(`would run ${script}`);
|
|
1692
|
+
return 0;
|
|
1693
|
+
});
|
|
1694
|
+
```
|
|
1695
|
+
@example
|
|
1696
|
+
```ts
|
|
1697
|
+
const code = await main(['--version']);
|
|
1698
|
+
```
|
|
1699
|
+
*/
|
|
1700
|
+
async function main(argv, run = runScript) {
|
|
1701
|
+
const { values, positionals } = parseArgs({
|
|
1702
|
+
args: argv,
|
|
1703
|
+
allowPositionals: true,
|
|
1704
|
+
options: {
|
|
1705
|
+
css: { type: "boolean" },
|
|
1706
|
+
html: { type: "boolean" },
|
|
1707
|
+
"target-html": { type: "boolean" },
|
|
1708
|
+
"shadow-html": { type: "boolean" },
|
|
1709
|
+
"target-css": { type: "boolean" },
|
|
1710
|
+
"shadow-css": { type: "boolean" },
|
|
1711
|
+
js: { type: "boolean" },
|
|
1712
|
+
dir: { type: "string" },
|
|
1713
|
+
namespace: { type: "string" },
|
|
1714
|
+
port: { type: "string" },
|
|
1715
|
+
force: { type: "boolean" },
|
|
1716
|
+
"dry-run": { type: "boolean" },
|
|
1717
|
+
help: {
|
|
1718
|
+
type: "boolean",
|
|
1719
|
+
short: "h"
|
|
1720
|
+
},
|
|
1721
|
+
version: {
|
|
1722
|
+
type: "boolean",
|
|
1723
|
+
short: "v"
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
});
|
|
1727
|
+
const command = positionals[0];
|
|
1728
|
+
if (values.version) {
|
|
1729
|
+
console.log(await readVersion());
|
|
1730
|
+
return 0;
|
|
1731
|
+
}
|
|
1732
|
+
if (values.help || !command) {
|
|
1733
|
+
console.log(USAGE);
|
|
1734
|
+
return 0;
|
|
1735
|
+
}
|
|
1736
|
+
if (command === "new") {
|
|
1737
|
+
const tag = positionals[1];
|
|
1738
|
+
if (!tag) {
|
|
1739
|
+
console.error("mc new: missing component tag (e.g. mc new my-card)");
|
|
1740
|
+
return 1;
|
|
1741
|
+
}
|
|
1742
|
+
const assets = [];
|
|
1743
|
+
const flags = values;
|
|
1744
|
+
for (const [flag, kind] of Object.entries(ASSET_FLAGS)) if (flags[flag]) assets.push(kind);
|
|
1745
|
+
if (values["dry-run"]) {
|
|
1746
|
+
const { files } = scaffoldComponent(tag, {
|
|
1747
|
+
assets,
|
|
1748
|
+
js: values.js
|
|
1749
|
+
});
|
|
1750
|
+
const root = values.dir ?? "src";
|
|
1751
|
+
for (const rel of Object.keys(files).sort()) console.log(`would create ${createdPath(root, rel)}`);
|
|
1752
|
+
return 0;
|
|
1753
|
+
}
|
|
1754
|
+
const root = values.dir ?? "src";
|
|
1755
|
+
let written;
|
|
1756
|
+
try {
|
|
1757
|
+
written = await writeScaffold(tag, root, {
|
|
1758
|
+
assets,
|
|
1759
|
+
js: values.js,
|
|
1760
|
+
force: values.force
|
|
1761
|
+
});
|
|
1762
|
+
} catch (e) {
|
|
1763
|
+
console.error(e.message);
|
|
1764
|
+
return 1;
|
|
1765
|
+
}
|
|
1766
|
+
for (const rel of written) console.log(`created ${createdPath(root, rel)}`);
|
|
1767
|
+
return 0;
|
|
1768
|
+
}
|
|
1769
|
+
if (command === "create") {
|
|
1770
|
+
const namespace = values.namespace;
|
|
1771
|
+
if (!namespace) {
|
|
1772
|
+
console.error("mc create: missing --namespace (e.g. mc create --namespace mc)");
|
|
1773
|
+
return 1;
|
|
1774
|
+
}
|
|
1775
|
+
const root = values.dir ?? namespace;
|
|
1776
|
+
let files;
|
|
1777
|
+
try {
|
|
1778
|
+
({files} = scaffoldProject(namespace));
|
|
1779
|
+
} catch (e) {
|
|
1780
|
+
console.error(e.message);
|
|
1781
|
+
return 1;
|
|
1782
|
+
}
|
|
1783
|
+
if (values["dry-run"]) {
|
|
1784
|
+
for (const rel of Object.keys(files).sort()) console.log(`would create ${createdPath(root, rel)}`);
|
|
1785
|
+
return 0;
|
|
1786
|
+
}
|
|
1787
|
+
let written;
|
|
1788
|
+
try {
|
|
1789
|
+
written = await writeProject(namespace, root, { force: values.force });
|
|
1790
|
+
} catch (e) {
|
|
1791
|
+
console.error(e.message);
|
|
1792
|
+
return 1;
|
|
1793
|
+
}
|
|
1794
|
+
for (const rel of written) console.log(`created ${createdPath(root, rel)}`);
|
|
1795
|
+
console.log(`\nproject "${namespace}" created in ${root}/ — run: cd ${root} && pnpm install && pnpm dev`);
|
|
1796
|
+
return 0;
|
|
1797
|
+
}
|
|
1798
|
+
if (command === "dev") return run("dev", { port: values.port ? Number(values.port) : 5173 });
|
|
1799
|
+
if (command === "bundle") return run("bundle");
|
|
1800
|
+
if (command === "preview") return run("preview", { port: values.port ? Number(values.port) : 5174 });
|
|
1801
|
+
if (command === "clean") return run("clean");
|
|
1802
|
+
if (command === "check") return run("check");
|
|
1803
|
+
console.error(`mc: unknown command "${command}"\n\n${USAGE}`);
|
|
1804
|
+
return 1;
|
|
1805
|
+
}
|
|
1806
|
+
//#endregion
|
|
1807
|
+
//#region src/cli/index.ts
|
|
2
1808
|
/**
|
|
3
1809
|
Executable entry point of the `mc` CLI (package.json "bin").
|
|
4
1810
|
|
|
@@ -8,5 +1814,5 @@ in and pushes the resulting exit code out.
|
|
|
8
1814
|
|
|
9
1815
|
@module CLI/Index
|
|
10
1816
|
*/
|
|
11
|
-
import { main } from './dispatch.js';
|
|
12
1817
|
process.exitCode = await main(process.argv.slice(2));
|
|
1818
|
+
//#endregion
|