@mizen-labs/cli 0.0.0-stage → 0.1.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.
@@ -0,0 +1,176 @@
1
+ import { documentFunction, imports, isNode, jsxName, literal, literalTrue, nameOf, nodes, parseSource, } from "../doctor/syntax.js";
2
+ import { changes, conflict, position, wrapElement, } from "./edit.js";
3
+ import { bindingName, importEdit, importedName, specifier } from "./imports.js";
4
+ const SELF_CLOSING = /\/\s*>$/u;
5
+ const LINE_LEAD = /^\r?\n[ \t]*/u;
6
+ function single(elements, tag) {
7
+ const matches = elements.filter((node) => jsxName(node) === tag);
8
+ if (matches.length !== 1) {
9
+ return conflict(`Setup requires exactly one ${tag} in the root document.`);
10
+ }
11
+ return matches[0];
12
+ }
13
+ function inside(parent, node) {
14
+ return (position(node, "start") > position(parent, "start") &&
15
+ position(node, "end") < position(parent, "end"));
16
+ }
17
+ function themeBinding(ast, exported) {
18
+ const existing = importedName(ast, exported, "@mizen-labs/ui/theme") ??
19
+ importedName(ast, exported, "@mizen-labs/ui");
20
+ return {
21
+ imported: existing !== null,
22
+ name: existing ?? bindingName(ast, exported),
23
+ };
24
+ }
25
+ function hydrationChange(html) {
26
+ const opening = html.openingElement;
27
+ if (!(isNode(opening) && Array.isArray(opening.attributes))) {
28
+ return conflict("The html element is not editable.");
29
+ }
30
+ if (opening.attributes.some((node) => isNode(node) && node.type === "JSXSpreadAttribute")) {
31
+ return conflict("Spread html attributes need manual hydration-warning review.");
32
+ }
33
+ const attributes = opening.attributes.filter((node) => isNode(node) && nameOf(node.name) === "suppressHydrationWarning");
34
+ if (attributes.length) {
35
+ const { value } = attributes[0];
36
+ if (!hydrationEnabled(value)) {
37
+ return conflict("Keep an explicit boolean suppressHydrationWarning on html before setup.");
38
+ }
39
+ return [];
40
+ }
41
+ const at = position(opening, "end") - 1;
42
+ return [{ end: at, start: at, text: " suppressHydrationWarning" }];
43
+ }
44
+ function providerChanges(source, elements, body, name) {
45
+ const existing = elements.filter((node) => jsxName(node) === name);
46
+ if (existing.length) {
47
+ if (existing.length !== 1 || !inside(body, existing[0])) {
48
+ return conflict("Existing ThemeProvider placement requires manual review.");
49
+ }
50
+ return [];
51
+ }
52
+ if (!isNode(body.closingElement)) {
53
+ return conflict("Self-closing body elements need manual setup.");
54
+ }
55
+ const start = position(body.openingElement, "end");
56
+ const end = position(body.closingElement, "start");
57
+ if (start === end) {
58
+ return [{ end, start, text: `<${name}>{null}</${name}>` }];
59
+ }
60
+ const inner = source.slice(start, end);
61
+ if (!inner.includes("\n")) {
62
+ return [
63
+ { end: start, start, text: `<${name}>` },
64
+ { end, start: end, text: `</${name}>` },
65
+ ];
66
+ }
67
+ // Children on their own lines get the provider as a block around them.
68
+ const first = start + inner.length - inner.trimStart().length;
69
+ const last = start + inner.trimEnd().length;
70
+ return [wrapElement(source, first, last, name)];
71
+ }
72
+ // A new head before body: on its own lines when body starts its line.
73
+ function headBefore(source, at, name) {
74
+ const lineStart = source.lastIndexOf("\n", at - 1) + 1;
75
+ const indent = source.slice(lineStart, at);
76
+ if (indent.trim()) {
77
+ return `<head><${name} /></head>`;
78
+ }
79
+ const unit = indent.includes("\t") ? "\t" : " ";
80
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
81
+ return `<head>${newline}${indent}${unit}<${name} />${newline}${indent}</head>${newline}${indent}`;
82
+ }
83
+ // An existing ThemeScript must be the first child of the only head.
84
+ function existingScript(heads, existing) {
85
+ if (existing.length !== 1 ||
86
+ heads.length !== 1 ||
87
+ !inside(heads[0], existing[0])) {
88
+ return conflict("Existing ThemeScript placement requires manual review.");
89
+ }
90
+ const first = nodes(heads[0])
91
+ .filter((node) => jsxName(node))
92
+ .at(1);
93
+ if (first !== existing[0]) {
94
+ return conflict("Place ThemeScript first in head before setup.");
95
+ }
96
+ return [];
97
+ }
98
+ function scriptChanges(source, elements, html, body, name) {
99
+ const existing = elements.filter((node) => jsxName(node) === name);
100
+ const heads = elements.filter((node) => jsxName(node) === "head");
101
+ if (existing.length) {
102
+ return existingScript(heads, existing);
103
+ }
104
+ if (!heads.length) {
105
+ if (!inside(html, body)) {
106
+ return conflict("The root body is not inside html.");
107
+ }
108
+ const at = position(body, "start");
109
+ return [{ end: at, start: at, text: headBefore(source, at, name) }];
110
+ }
111
+ const head = single(elements, "head");
112
+ if (isNode(head.closingElement)) {
113
+ const at = position(head.openingElement, "end");
114
+ // Keep the head's own line break and indent before the new child.
115
+ const lead = LINE_LEAD.exec(source.slice(at))?.[0] ?? "";
116
+ return [{ end: at, start: at, text: `${lead}<${name} />` }];
117
+ }
118
+ const opening = source
119
+ .slice(position(head, "start"), position(head, "end"))
120
+ .replace(SELF_CLOSING, ">");
121
+ return [
122
+ {
123
+ end: position(head, "end"),
124
+ start: position(head, "start"),
125
+ text: `${opening}<${name} /></head>`,
126
+ },
127
+ ];
128
+ }
129
+ export function themeSource(source, framework) {
130
+ const ast = parseSource(source);
131
+ const document = documentFunction(ast, framework);
132
+ if (!document) {
133
+ return conflict("The root document is delegated or computed. Configure theme setup manually before init.");
134
+ }
135
+ const elements = nodes(document).filter((node) => jsxName(node));
136
+ const html = single(elements, "html");
137
+ const body = single(elements, "body");
138
+ const provider = themeBinding(ast, "ThemeProvider");
139
+ const script = themeBinding(ast, "ThemeScript");
140
+ assertBindings(ast, document, [provider.name, script.name]);
141
+ const edits = [
142
+ ...hydrationChange(html),
143
+ ...providerChanges(source, elements, body, provider.name),
144
+ ...scriptChanges(source, elements, html, body, script.name),
145
+ ];
146
+ const needed = [provider, script].filter((binding) => !binding.imported);
147
+ if (needed.length) {
148
+ const names = [
149
+ provider.imported ? "" : specifier("ThemeProvider", provider.name),
150
+ script.imported ? "" : specifier("ThemeScript", script.name),
151
+ ].filter(Boolean);
152
+ edits.push(importEdit(ast, `import { ${names.join(", ")} } from "@mizen-labs/ui/theme";`, source));
153
+ }
154
+ return changes(source, edits);
155
+ }
156
+ function assertBindings(ast, document, bindings) {
157
+ const themeImports = imports(ast).filter((node) => ["@mizen-labs/ui", "@mizen-labs/ui/theme"].includes(literal(node.source) ?? ""));
158
+ const namespaces = themeImports
159
+ .flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
160
+ .filter((node) => isNode(node) && node.type === "ImportNamespaceSpecifier");
161
+ if (namespaces.length) {
162
+ conflict("Namespace Mizen imports require manual theme setup; init will not risk duplicating providers.");
163
+ }
164
+ const declared = nodes(document)
165
+ .flatMap((node) => node.type === "VariableDeclarator" ? nodes(node.id) : nodes(node.params))
166
+ .map(nameOf);
167
+ if (bindings.some((name) => declared.includes(name))) {
168
+ conflict("A theme import is shadowed in the root document. Resolve the binding before setup.");
169
+ }
170
+ }
171
+ function hydrationEnabled(value) {
172
+ return (value === null ||
173
+ (isNode(value) &&
174
+ value.type === "JSXExpressionContainer" &&
175
+ literalTrue(value.expression)));
176
+ }
@@ -0,0 +1,72 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { conflict, unchanged } from "./edit.js";
5
+ async function stage(project, edit) {
6
+ const path = join(project, edit.path);
7
+ await unchanged(path, edit.before);
8
+ // biome-ignore lint/suspicious/noBitwiseOperators: Preserve the existing POSIX permission bits.
9
+ const mode = edit.before === null ? 0o644 : (await lstat(path)).mode & 0o777;
10
+ const staged = `${path}.mizen-${randomUUID()}.tmp`;
11
+ try {
12
+ await writeFile(staged, edit.after, { flag: "wx", mode });
13
+ }
14
+ catch (error) {
15
+ await rm(staged, { force: true });
16
+ throw error;
17
+ }
18
+ return staged;
19
+ }
20
+ export async function restoreFiles(project, edits) {
21
+ for (const edit of edits.toReversed()) {
22
+ // biome-ignore lint/performance/noAwaitInLoops: Rollback preserves commit order and refuses concurrent changes.
23
+ await restoreFile(project, edit);
24
+ }
25
+ }
26
+ async function restoreFile(project, edit) {
27
+ const path = join(project, edit.path);
28
+ const current = await readFile(path, "utf8");
29
+ if (current === edit.before) {
30
+ return;
31
+ }
32
+ if (current !== edit.after) {
33
+ return conflict(`Concurrent changes prevent rollback of ${edit.path}. Use the init journal for manual recovery.`);
34
+ }
35
+ if (edit.before === null) {
36
+ await rm(path);
37
+ return;
38
+ }
39
+ const temporary = await stage(project, {
40
+ ...edit,
41
+ after: edit.before,
42
+ before: edit.after,
43
+ });
44
+ await rename(temporary, path);
45
+ }
46
+ export async function applyFiles(project, edits, replace = rename) {
47
+ const staged = new Map();
48
+ const committed = [];
49
+ try {
50
+ for (const edit of edits) {
51
+ // biome-ignore lint/performance/noAwaitInLoops: Stage sequentially so every temporary file is tracked for cleanup on failure.
52
+ staged.set(edit, await stage(project, edit));
53
+ }
54
+ for (const edit of edits) {
55
+ // biome-ignore lint/performance/noAwaitInLoops: Revalidate immediately before each ordered atomic replacement.
56
+ await unchanged(join(project, edit.path), edit.before);
57
+ const temporary = staged.get(edit);
58
+ if (!temporary) {
59
+ return conflict("A staged file is missing; inspect the init journal.");
60
+ }
61
+ await replace(temporary, join(project, edit.path));
62
+ committed.push(edit);
63
+ }
64
+ }
65
+ catch (error) {
66
+ await restoreFiles(project, committed);
67
+ throw error;
68
+ }
69
+ finally {
70
+ await Promise.all([...staged.values()].map((path) => rm(path, { force: true })));
71
+ }
72
+ }
package/dist/output.js ADDED
@@ -0,0 +1,78 @@
1
+ // Shared terminal output: semantic styles, the status gutter, aligned rows,
2
+ // and counts. Commands format through these so every screen reads alike.
3
+ const LABEL_WIDTH = 16;
4
+ const GUTTER = " ";
5
+ const plain = (text) => text;
6
+ function sgr(open, close) {
7
+ return (text) => `\u001b[${open}m${text}\u001b[${close}m`;
8
+ }
9
+ // --no-color and NO_COLOR win, then FORCE_COLOR, then the stream itself:
10
+ // color only on a terminal whose TERM is not dumb.
11
+ function colorOn(stream) {
12
+ if (process.env.NO_COLOR) {
13
+ return false;
14
+ }
15
+ const forced = process.env.FORCE_COLOR;
16
+ if (forced !== undefined) {
17
+ return forced !== "0";
18
+ }
19
+ return Boolean(stream.isTTY) && process.env.TERM !== "dumb";
20
+ }
21
+ /** No styling, for JSON and pipes. */
22
+ export const unstyled = {
23
+ bold: plain,
24
+ command: plain,
25
+ fail: plain,
26
+ heading: plain,
27
+ pass: plain,
28
+ warn: plain,
29
+ };
30
+ /** Styles for one stream; each stream decides for itself. */
31
+ export function styleFor(stream) {
32
+ if (!colorOn(stream)) {
33
+ return unstyled;
34
+ }
35
+ const bold = sgr(1, 22);
36
+ const magenta = sgr(35, 39);
37
+ return {
38
+ bold,
39
+ command: sgr(36, 39),
40
+ fail: sgr(31, 39),
41
+ heading: (text) => bold(magenta(text)),
42
+ pass: sgr(32, 39),
43
+ warn: sgr(33, 39),
44
+ };
45
+ }
46
+ export const glyphs = { fail: "✗", pass: "✓", warn: "!" };
47
+ /** A status line: the glyph in column 0, the text from column 2. */
48
+ export function status(style, kind, text) {
49
+ return `${style[kind](glyphs[kind])} ${text}`;
50
+ }
51
+ /**
52
+ * A label and value in the shared columns: label from column 2, value from
53
+ * column 18. A long label puts its value on the next line; each further
54
+ * line of the value aligns under the first.
55
+ */
56
+ export function row(label, value) {
57
+ const lines = typeof value === "string" ? value.split("\n") : [...value];
58
+ const pad = " ".repeat(GUTTER.length + LABEL_WIDTH);
59
+ const head = label.length < LABEL_WIDTH
60
+ ? `${GUTTER}${label.padEnd(LABEL_WIDTH)}${lines[0] ?? ""}`
61
+ : `${GUTTER}${label}\n${pad}${lines[0] ?? ""}`;
62
+ return [head, ...lines.slice(1).map((line) => `${pad}${line}`)].join("\n");
63
+ }
64
+ /** A next step: what it gets you, then the command on its own line. */
65
+ export function nextStep(style, benefit, command) {
66
+ return `${GUTTER}${benefit}:\n${GUTTER}${GUTTER}${style.command(command)}`;
67
+ }
68
+ export function plural(count, one, many = `${one}s`) {
69
+ return `${count.toLocaleString("en-US")} ${count === 1 ? one : many}`;
70
+ }
71
+ /** Indents every line of a block by the gutter. */
72
+ export function indent(text, depth = 1) {
73
+ const prefix = GUTTER.repeat(depth);
74
+ return text
75
+ .split("\n")
76
+ .map((line) => (line ? `${prefix}${line}` : line))
77
+ .join("\n");
78
+ }
@@ -0,0 +1,43 @@
1
+ import { isNode, jsxName, nodes } from "../doctor/syntax.js";
2
+ import { conflict, position } from "../init/edit.js";
3
+ import { bindingName, importEdit, importedName, specifier, } from "../init/imports.js";
4
+ import { bootstrapBefore, bootstrapCalls, providerNames, renderCall, } from "./render.js";
5
+ export function bootstrapEdits(source, ast, tree) {
6
+ if (bootstrapBefore(ast)) {
7
+ return [];
8
+ }
9
+ if (bootstrapCalls(ast).length) {
10
+ return conflict("Call initializeTheme once before createRoot; review existing bootstrap order before init.");
11
+ }
12
+ ensureDefaultProvider(ast, tree);
13
+ const existing = [
14
+ "@mizen-labs/ui/theme/initialize",
15
+ "@mizen-labs/ui/theme",
16
+ "@mizen-labs/ui",
17
+ ]
18
+ .map((sourcePath) => importedName(ast, "initializeTheme", sourcePath))
19
+ .find((binding) => binding !== null);
20
+ const name = existing ?? bindingName(ast, "initializeTheme");
21
+ const call = renderCall(ast);
22
+ if (!call) {
23
+ return conflict("No conventional root render call was found.");
24
+ }
25
+ const at = position(call, "start");
26
+ const newline = source.includes("\r\n") ? "\r\n" : "\n";
27
+ const edits = [
28
+ { end: at, start: at, text: `${name}();${newline}` },
29
+ ];
30
+ if (!existing) {
31
+ edits.push(importEdit(ast, `import { ${specifier("initializeTheme", name)} } from "@mizen-labs/ui/theme/initialize";`, source));
32
+ }
33
+ return edits;
34
+ }
35
+ function ensureDefaultProvider(ast, tree) {
36
+ const provider = nodes(tree).find((node) => providerNames(ast).includes(jsxName(node) ?? ""));
37
+ if (provider &&
38
+ isNode(provider.openingElement) &&
39
+ Array.isArray(provider.openingElement.attributes) &&
40
+ provider.openingElement.attributes.length) {
41
+ conflict("Initialize the theme with options matching your custom ThemeProvider before init.");
42
+ }
43
+ }
@@ -0,0 +1,50 @@
1
+ import { createRequire } from "node:module";
2
+ import { join } from "node:path";
3
+ import { check } from "../doctor/report.js";
4
+ import { imports, literal, parseSource } from "../doctor/syntax.js";
5
+ import { viteEntry } from "./entry.js";
6
+ import { bootstrapBefore, providerCovers, renderArgument } from "./render.js";
7
+ export async function viteSetupChecks(project) {
8
+ const entry = await viteEntry(project);
9
+ if (!entry) {
10
+ return [
11
+ check("setup.root", "unverified", "index.html does not select one supported src/main entry.", [join(project, "index.html")], "Use a single module entry at src/main.tsx or src/main.jsx, or review custom Vite entry wiring manually."),
12
+ ];
13
+ }
14
+ const path = join(project, entry.path);
15
+ try {
16
+ const ast = parseSource(entry.source);
17
+ const css = imports(ast).some((node) => literal(node.source) === "@mizen-labs/ui/styles.css" &&
18
+ Array.isArray(node.specifiers) &&
19
+ node.specifiers.length === 0);
20
+ const tree = renderArgument(ast);
21
+ const provider = tree !== null && providerCovers(ast, tree);
22
+ return [
23
+ bootstrapCheck(project, ast, path),
24
+ check("setup.bundling", "pass", "Client Vite bundles package CSS imports; no SSR bundling setting is required.", [path]),
25
+ check("setup.stylesheet", css ? "pass" : "unverified", css
26
+ ? "The client entry imports Mizen's compiled stylesheet."
27
+ : "No direct Mizen stylesheet import was verified in the client entry.", [path], "Import @mizen-labs/ui/styles.css in the module loaded by index.html."),
28
+ check("setup.theme", provider ? "pass" : "unverified", provider
29
+ ? "ThemeProvider covers the mounted client tree. No SSR ThemeScript is needed for this client-only entry."
30
+ : "A ThemeProvider covering createRoot(...).render(...) was not verified.", [path], "Wrap the mounted React tree in ThemeProvider from @mizen-labs/ui/theme; keep existing StrictMode and app providers."),
31
+ ];
32
+ }
33
+ catch {
34
+ return [
35
+ check("setup.root", "unverified", "The Vite client entry could not be parsed; it was not executed.", [path], "Check entry syntax and review custom mounting code manually."),
36
+ ];
37
+ }
38
+ }
39
+ function bootstrapCheck(project, ast, path) {
40
+ if (!bootstrapBefore(ast)) {
41
+ return check("setup.bootstrap", "unverified", "A synchronous initializeTheme call before React mounting was not verified.", [path], "Call initializeTheme from @mizen-labs/ui/theme/initialize before createRoot, using options matching ThemeProvider.");
42
+ }
43
+ try {
44
+ createRequire(join(project, "package.json")).resolve("@mizen-labs/ui/theme/initialize");
45
+ }
46
+ catch {
47
+ return check("package.bootstrap", "error", "This installed UI build does not expose client theme initialization.", [join(project, "package.json")], "Rebuild and install a matching Mizen UI archive before initializing the app.");
48
+ }
49
+ return check("setup.bootstrap", "pass", "Theme initialization is declared before the client render call.", [path]);
50
+ }
@@ -0,0 +1,46 @@
1
+ import { join } from "node:path";
2
+ import { parse } from "parse5";
3
+ import { readOptional } from "../doctor/files.js";
4
+ const ENTRIES = new Set([
5
+ "src/main.tsx",
6
+ "src/main.jsx",
7
+ "src/main.ts",
8
+ "src/main.js",
9
+ ]);
10
+ const DOT_PREFIX = /^\.\//u;
11
+ function htmlNodes(node) {
12
+ return [
13
+ node,
14
+ ...("childNodes" in node ? node.childNodes.flatMap(htmlNodes) : []),
15
+ ];
16
+ }
17
+ function attribute(node, name) {
18
+ return "attrs" in node
19
+ ? node.attrs.find((item) => item.name === name)?.value
20
+ : undefined;
21
+ }
22
+ function htmlEntry(source) {
23
+ const document = htmlNodes(parse(source));
24
+ if (document.some((node) => node.nodeName === "base")) {
25
+ return null;
26
+ }
27
+ const scripts = document.filter((node) => node.nodeName === "script" && attribute(node, "type") === "module");
28
+ if (scripts.length !== 1) {
29
+ return null;
30
+ }
31
+ const src = attribute(scripts[0], "src");
32
+ if (!src) {
33
+ return null;
34
+ }
35
+ const path = src.startsWith("/") ? src.slice(1) : src.replace(DOT_PREFIX, "");
36
+ return ENTRIES.has(path) ? path : null;
37
+ }
38
+ export async function viteEntry(project) {
39
+ const html = await readOptional(join(project, "index.html"));
40
+ const path = html === null ? null : htmlEntry(html);
41
+ if (!path) {
42
+ return null;
43
+ }
44
+ const source = await readOptional(join(project, path));
45
+ return source === null ? null : { html, path, source };
46
+ }
@@ -0,0 +1,66 @@
1
+ import { lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { imports, isNode, literal, parseSource } from "../doctor/syntax.js";
4
+ import { changes, conflict, fileEdit, position, sourceFile, wrapElement, } from "../init/edit.js";
5
+ import { bindingName, importEdit, importedName, specifier, } from "../init/imports.js";
6
+ import { bootstrapEdits } from "./bootstrap.js";
7
+ import { viteEntry } from "./entry.js";
8
+ import { hasProvider, providerCovers, renderArgument } from "./render.js";
9
+ export async function viteRootEdit(project) {
10
+ const entry = await viteEntry(project);
11
+ if (!entry) {
12
+ return conflict("index.html must load one conventional src/main.tsx or src/main.jsx module before init.");
13
+ }
14
+ const htmlStat = await lstat(join(project, "index.html"));
15
+ if (!htmlStat.isFile()) {
16
+ return conflict("Review the linked or custom Vite index.html before setup.");
17
+ }
18
+ const file = await sourceFile(project, [entry.path]);
19
+ return fileEdit(file.path, file.before, clientTheme(file.source));
20
+ }
21
+ function clientTheme(source) {
22
+ const ast = parseSource(source);
23
+ const tree = renderArgument(ast);
24
+ if (!tree) {
25
+ return conflict("Use a conventional top-level createRoot(...).render(<App />) entry, or configure Mizen manually.");
26
+ }
27
+ const namespace = imports(ast)
28
+ .filter((node) => [
29
+ "@mizen-labs/ui/theme",
30
+ "@mizen-labs/ui",
31
+ "@mizen-labs/ui/theme/initialize",
32
+ ].includes(literal(node.source) ?? ""))
33
+ .flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
34
+ .some((node) => isNode(node) && node.type === "ImportNamespaceSpecifier");
35
+ if (namespace) {
36
+ return conflict("Namespace Mizen imports require manual provider review.");
37
+ }
38
+ const provider = importedName(ast, "ThemeProvider", "@mizen-labs/ui/theme") ??
39
+ importedName(ast, "ThemeProvider", "@mizen-labs/ui");
40
+ if (hasProvider(ast, tree) && !providerCovers(ast, tree)) {
41
+ return conflict("An existing provider covers only part of the render tree. Review it before init.");
42
+ }
43
+ const edits = [];
44
+ const covered = providerCovers(ast, tree);
45
+ if (!covered) {
46
+ const name = provider ?? bindingName(ast, "ThemeProvider");
47
+ const start = position(tree, "start");
48
+ const end = position(tree, "end");
49
+ edits.push(wrapElement(source, start, end, name));
50
+ if (!provider) {
51
+ edits.push(importEdit(ast, `import { ${specifier("ThemeProvider", name)} } from "@mizen-labs/ui/theme";`, source));
52
+ }
53
+ }
54
+ edits.push(...bootstrapEdits(source, ast, tree));
55
+ const themed = changes(source, edits);
56
+ const result = parseSource(themed);
57
+ const css = imports(result).some((node) => literal(node.source) === "@mizen-labs/ui/styles.css" &&
58
+ Array.isArray(node.specifiers) &&
59
+ node.specifiers.length === 0);
60
+ const after = css
61
+ ? themed
62
+ : changes(themed, [
63
+ importEdit(result, 'import "@mizen-labs/ui/styles.css";', themed, "first"),
64
+ ]);
65
+ return after;
66
+ }
@@ -0,0 +1,144 @@
1
+ import { imports, isNode, jsxName, literal, nameOf, nodes, } from "../doctor/syntax.js";
2
+ function bindings(ast, source, exported) {
3
+ return imports(ast)
4
+ .filter((node) => literal(node.source) === source)
5
+ .flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
6
+ .filter((node) => isNode(node) &&
7
+ node.importKind !== "type" &&
8
+ nameOf(node.imported) === exported)
9
+ .map((node) => (isNode(node) ? nameOf(node.local) : null))
10
+ .filter((name) => name !== null);
11
+ }
12
+ function namespaces(ast, source) {
13
+ return imports(ast)
14
+ .filter((node) => literal(node.source) === source)
15
+ .flatMap((node) => (Array.isArray(node.specifiers) ? node.specifiers : []))
16
+ .filter((node) => isNode(node) &&
17
+ ["ImportDefaultSpecifier", "ImportNamespaceSpecifier"].includes(node.type))
18
+ .map((node) => (isNode(node) ? nameOf(node.local) : null))
19
+ .filter((name) => name !== null);
20
+ }
21
+ function callsCreateRoot(ast, value) {
22
+ if (!isNode(value) || value.type !== "CallExpression") {
23
+ return false;
24
+ }
25
+ const { callee } = value;
26
+ if (bindings(ast, "react-dom/client", "createRoot").includes(nameOf(callee) ?? "")) {
27
+ return true;
28
+ }
29
+ if (!isNode(callee) ||
30
+ callee.type !== "MemberExpression" ||
31
+ callee.computed) {
32
+ return false;
33
+ }
34
+ return (nameOf(callee.property) === "createRoot" &&
35
+ namespaces(ast, "react-dom/client").includes(nameOf(callee.object) ?? ""));
36
+ }
37
+ export function renderCall(ast) {
38
+ if (!(isNode(ast.program) && Array.isArray(ast.program.body))) {
39
+ return null;
40
+ }
41
+ const statements = ast.program.body.filter((node) => isNode(node) && node.type === "ExpressionStatement");
42
+ const renders = statements
43
+ .map((node) => node.expression)
44
+ .filter((node) => {
45
+ if (!isNode(node) ||
46
+ node.type !== "CallExpression" ||
47
+ !isNode(node.callee)) {
48
+ return false;
49
+ }
50
+ const { callee } = node;
51
+ return (callee.type === "MemberExpression" &&
52
+ !callee.computed &&
53
+ nameOf(callee.property) === "render" &&
54
+ callsCreateRoot(ast, callee.object));
55
+ });
56
+ if (renders.length !== 1 ||
57
+ !Array.isArray(renders[0].arguments) ||
58
+ renders[0].arguments.length !== 1) {
59
+ return null;
60
+ }
61
+ return renders[0];
62
+ }
63
+ export function renderArgument(ast) {
64
+ const call = renderCall(ast);
65
+ if (!(call && Array.isArray(call.arguments))) {
66
+ return null;
67
+ }
68
+ const [tree] = call.arguments;
69
+ return isNode(tree) && ["JSXElement", "JSXFragment"].includes(tree.type)
70
+ ? tree
71
+ : null;
72
+ }
73
+ export function providerNames(ast) {
74
+ return ["@mizen-labs/ui/theme", "@mizen-labs/ui"].flatMap((source) => bindings(ast, source, "ThemeProvider"));
75
+ }
76
+ function transparentWrapper(ast, tree) {
77
+ if (tree.type === "JSXFragment") {
78
+ return true;
79
+ }
80
+ if (bindings(ast, "react", "StrictMode").includes(jsxName(tree) ?? "")) {
81
+ return true;
82
+ }
83
+ if (!(isNode(tree.openingElement) && isNode(tree.openingElement.name))) {
84
+ return false;
85
+ }
86
+ const { name } = tree.openingElement;
87
+ return (name.type === "JSXMemberExpression" &&
88
+ ["StrictMode", "Fragment"].includes(nameOf(name.property) ?? "") &&
89
+ namespaces(ast, "react").includes(nameOf(name.object) ?? ""));
90
+ }
91
+ export function providerCovers(ast, tree) {
92
+ if (providerNames(ast).includes(jsxName(tree) ?? "")) {
93
+ return true;
94
+ }
95
+ if (!(transparentWrapper(ast, tree) && Array.isArray(tree.children))) {
96
+ return false;
97
+ }
98
+ const children = tree.children.filter((node) => {
99
+ if (!isNode(node)) {
100
+ return false;
101
+ }
102
+ if (node.type === "JSXText") {
103
+ return String(node.value).trim().length > 0;
104
+ }
105
+ return !(node.type === "JSXExpressionContainer" &&
106
+ isNode(node.expression) &&
107
+ node.expression.type === "JSXEmptyExpression");
108
+ });
109
+ return (children.length === 1 &&
110
+ isNode(children[0]) &&
111
+ providerCovers(ast, children[0]));
112
+ }
113
+ export function hasProvider(ast, tree) {
114
+ return nodes(tree).some((node) => providerNames(ast).includes(jsxName(node) ?? ""));
115
+ }
116
+ export function bootstrapCalls(ast) {
117
+ if (!(isNode(ast.program) && Array.isArray(ast.program.body))) {
118
+ return [];
119
+ }
120
+ const names = [
121
+ "@mizen-labs/ui/theme/initialize",
122
+ "@mizen-labs/ui/theme",
123
+ "@mizen-labs/ui",
124
+ ].flatMap((source) => bindings(ast, source, "initializeTheme"));
125
+ return ast.program.body
126
+ .filter((node) => isNode(node) && node.type === "ExpressionStatement")
127
+ .map((node) => node.expression)
128
+ .filter((node) => isNode(node) &&
129
+ node.type === "CallExpression" &&
130
+ names.includes(nameOf(node.callee) ?? ""));
131
+ }
132
+ export function bootstrapBefore(ast) {
133
+ const render = renderCall(ast);
134
+ const calls = bootstrapCalls(ast);
135
+ return (render !== null &&
136
+ calls.length === 1 &&
137
+ typeof render.start === "number" &&
138
+ typeof calls[0].start === "number" &&
139
+ calls[0].start < render.start);
140
+ }
141
+ export function supportsInitialization(manifest) {
142
+ const map = manifest?.exports;
143
+ return !!map && typeof map === "object" && "./theme/initialize" in map;
144
+ }