@weatherboard/gyde-design 0.4.1 → 0.4.3
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/agentdocs.mjs +17 -1
- package/cli.mjs +11 -0
- package/package.json +1 -1
- package/theme-entry.mjs +30 -0
- package/themehost.mjs +119 -0
package/agentdocs.mjs
CHANGED
|
@@ -122,6 +122,9 @@ export function agentDoc({
|
|
|
122
122
|
* requirements rather than as facts about the implementation.
|
|
123
123
|
*/
|
|
124
124
|
verified = true,
|
|
125
|
+
// An older Gyde-emitted set may predate the app theme controller. Its
|
|
126
|
+
// component metadata is verified, but this optional capability is absent.
|
|
127
|
+
themeChoice = verified,
|
|
125
128
|
} = {}) {
|
|
126
129
|
const closure = verified
|
|
127
130
|
? `**Never pass \`className\` or \`style\` to a design-system component.** They do not
|
|
@@ -191,7 +194,9 @@ brand — do **not** reach for inline styles. Spread \`themeVars(theme)\` onto a
|
|
|
191
194
|
element you already own and set the component's tone to \`themed\`. It returns
|
|
192
195
|
CSS custom properties only, which is why it is not an escape hatch.
|
|
193
196
|
|
|
194
|
-
|
|
197
|
+
` : ""}
|
|
198
|
+
|
|
199
|
+
${themeChoice ? `## App theme choice
|
|
195
200
|
|
|
196
201
|
The design-system package supplies \`src/theme-bootstrap.js\` and exports
|
|
197
202
|
\`${systemPackage}/theme-choice\`. The product must wire both into its app shell:
|
|
@@ -209,6 +214,17 @@ The design-system package supplies \`src/theme-bootstrap.js\` and exports
|
|
|
209
214
|
replacement and repairs external attribute changes. Keep local route theme
|
|
210
215
|
overrides on inner containers so they do not overwrite the global choice.
|
|
211
216
|
|
|
217
|
+
Declare each browser host in \`gyde.config.json\` under \`design.themeHosts\`.
|
|
218
|
+
For a standard HTML entry, use
|
|
219
|
+
\`{ "appPath": "apps/web", "adapter": "html-inline", "html": "index.html", "entry": "src/main.js" }\`.
|
|
220
|
+
The HTML head must contain the exact emitted bootstrap as a synchronous inline
|
|
221
|
+
script before styles or external scripts, and load \`/src/main.js\` as a module.
|
|
222
|
+
That module must import and call \`startThemeChoice\` from
|
|
223
|
+
\`${systemPackage}/theme-choice\` immediately at module top level after that
|
|
224
|
+
import. Gyde's gate checks these files and refuses
|
|
225
|
+
an unsupported adapter; a framework-owned head needs its own adapter. Library
|
|
226
|
+
packages without a browser host leave \`themeHosts\` absent or empty.
|
|
227
|
+
|
|
212
228
|
The storage key is scoped to this design-system package. \`system\` is an
|
|
213
229
|
explicit stored choice that removes \`data-theme\` from the document root and
|
|
214
230
|
lets \`prefers-color-scheme\` decide. Unavailable storage keeps the current tab
|
package/cli.mjs
CHANGED
|
@@ -49,6 +49,7 @@ import { buildUsage, guidance, formatUsage } from "./usage.mjs";
|
|
|
49
49
|
import { record, gate, formatGate, adopt, trim, formatTrim, LEDGER_NOTE } from "./ratchet.mjs";
|
|
50
50
|
import { loadRules } from "./rules.mjs";
|
|
51
51
|
import { validateScopeShape, checkScopeAgreement, formatScope } from "./scope.mjs";
|
|
52
|
+
import { checkThemeHosts, formatThemeHosts } from "./themehost.mjs";
|
|
52
53
|
|
|
53
54
|
/**
|
|
54
55
|
* The single source of what would be written.
|
|
@@ -688,6 +689,16 @@ function cmdGate(root, config, { recordNewRules = false, trimLedger = false } =
|
|
|
688
689
|
return 1;
|
|
689
690
|
}
|
|
690
691
|
|
|
692
|
+
// Host wiring is a binary runtime contract, never an allowance entry. Check
|
|
693
|
+
// before the first-baseline path so recording debt cannot turn a missing
|
|
694
|
+
// pre-paint script into a passing gate.
|
|
695
|
+
const themeHosts = checkThemeHosts(root, design, { packages: ws.packages });
|
|
696
|
+
console.log(formatThemeHosts(themeHosts));
|
|
697
|
+
if (themeHosts.errors.length) {
|
|
698
|
+
writeEvidence(root, null, { ok: false, why: "declared theme host wiring is incomplete" });
|
|
699
|
+
return 1;
|
|
700
|
+
}
|
|
701
|
+
|
|
691
702
|
const roots = exportedComponents(root, systemPath);
|
|
692
703
|
const result = scan(root, { config: { ...design, componentRoots: roots ? new Set(roots) : null } });
|
|
693
704
|
|
package/package.json
CHANGED
package/theme-entry.mjs
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** Parse the linked JavaScript module before accepting theme startup wiring. */
|
|
2
|
+
import { parse } from "acorn";
|
|
3
|
+
|
|
4
|
+
const directCall = (node, binding) => node?.type === "CallExpression" &&
|
|
5
|
+
node.callee.type === "Identifier" && node.callee.name === binding &&
|
|
6
|
+
node.arguments.length === 0 && !node.optional;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Only an actual named import followed by a direct top-level call counts.
|
|
10
|
+
* Acorn's module AST excludes comments, strings, template literals, regex
|
|
11
|
+
* literals, branches and callbacks from this path. A syntax error is a failed
|
|
12
|
+
* static contract rather than a guess that source-looking text will execute.
|
|
13
|
+
*/
|
|
14
|
+
export function hasThemeStartup(source, systemPackage) {
|
|
15
|
+
let program;
|
|
16
|
+
try { program = parse(source, { ecmaVersion: "latest", sourceType: "module" }); }
|
|
17
|
+
catch { return false; }
|
|
18
|
+
for (const [index, node] of program.body.entries()) {
|
|
19
|
+
if (node.type !== "ImportDeclaration" ||
|
|
20
|
+
node.source.value !== `${systemPackage}/theme-choice`) continue;
|
|
21
|
+
const binding = node.specifiers.find((part) => part.type === "ImportSpecifier" &&
|
|
22
|
+
part.imported.type === "Identifier" && part.imported.name === "startThemeChoice")?.local.name;
|
|
23
|
+
if (!binding) continue;
|
|
24
|
+
const next = program.body[index + 1];
|
|
25
|
+
if (next?.type === "ExpressionStatement" && directCall(next.expression, binding)) return true;
|
|
26
|
+
if (next?.type === "VariableDeclaration" && next.kind === "const" &&
|
|
27
|
+
next.declarations.length === 1 && directCall(next.declarations[0].init, binding)) return true;
|
|
28
|
+
}
|
|
29
|
+
return false;
|
|
30
|
+
}
|
package/themehost.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/** Static host contract for app shells Gyde knows how to inspect. */
|
|
2
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join, resolve, relative, sep } from "node:path";
|
|
4
|
+
import { parse } from "parse5";
|
|
5
|
+
import { hasThemeStartup } from "./theme-entry.mjs";
|
|
6
|
+
|
|
7
|
+
const HTML_NAMESPACE = "http://www.w3.org/1999/xhtml";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Read browser-parsed elements in a plain HTML entry. The parser handles
|
|
11
|
+
* raw text, RCDATA, and template contents as the browser does, so markup
|
|
12
|
+
* written inside them cannot masquerade as executable scripts.
|
|
13
|
+
*/
|
|
14
|
+
function htmlElements(html) {
|
|
15
|
+
const nodes = [];
|
|
16
|
+
const visit = (node, parent = null) => {
|
|
17
|
+
if (node.tagName && node.namespaceURI === HTML_NAMESPACE) {
|
|
18
|
+
if (node.tagName === "noscript") return;
|
|
19
|
+
// An implied head is not evidence that the source declares one.
|
|
20
|
+
if (node.sourceCodeLocation) {
|
|
21
|
+
nodes.push({
|
|
22
|
+
name: node.tagName,
|
|
23
|
+
attrs: new Map(node.attrs.map(({ name, value }) => [name, value])),
|
|
24
|
+
parent,
|
|
25
|
+
inert: false,
|
|
26
|
+
index: node.sourceCodeLocation.startOffset,
|
|
27
|
+
text: node.childNodes?.filter((child) => child.nodeName === "#text")
|
|
28
|
+
.map((child) => child.value).join("") ?? "",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
parent = node.tagName;
|
|
32
|
+
}
|
|
33
|
+
// parse5 stores template children in `content`, separate from childNodes.
|
|
34
|
+
// Do not visit them: their scripts are inert in the host document.
|
|
35
|
+
for (const child of node.childNodes ?? []) visit(child, parent);
|
|
36
|
+
};
|
|
37
|
+
visit(parse(html, { sourceCodeLocationInfo: true, scriptingEnabled: true }));
|
|
38
|
+
return nodes;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function within(root, path) {
|
|
42
|
+
if (typeof path !== "string" || !path || path.startsWith("/")) return null;
|
|
43
|
+
const full = resolve(root, path);
|
|
44
|
+
const rel = relative(root, full);
|
|
45
|
+
return rel && rel !== ".." && !rel.startsWith(`..${sep}`) ? full : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function read(root, path) {
|
|
49
|
+
const full = within(root, path);
|
|
50
|
+
if (!full || !existsSync(full)) return null;
|
|
51
|
+
try { return readFileSync(full, "utf8"); } catch { return null; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* `html-inline` is intentionally narrow: it proves concrete wiring in a
|
|
56
|
+
* standard HTML entry and its directly linked module. Framework layouts need
|
|
57
|
+
* their own adapter; a text marker in config is never accepted as evidence.
|
|
58
|
+
*/
|
|
59
|
+
export function checkThemeHosts(root, design = {}, { packages = null } = {}) {
|
|
60
|
+
const declared = design.themeHosts;
|
|
61
|
+
if (declared === undefined) return { checked: 0, errors: [] };
|
|
62
|
+
if (!Array.isArray(declared)) return { checked: 0, errors: ["design.themeHosts must be an array"] };
|
|
63
|
+
const errors = [];
|
|
64
|
+
const systemPath = design.systemPath || "packages/design-system";
|
|
65
|
+
const systemPackage = design.systemPackage || `${design.scope}/design-system`;
|
|
66
|
+
const expected = read(root, `${systemPath}/src/theme-bootstrap.js`);
|
|
67
|
+
for (const [index, host] of declared.entries()) {
|
|
68
|
+
const label = `design.themeHosts[${index}]`;
|
|
69
|
+
if (!host || typeof host !== "object" || host.adapter !== "html-inline") {
|
|
70
|
+
errors.push(`${label}: unsupported adapter; this gate currently supports html-inline`);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const app = within(root, host.appPath);
|
|
74
|
+
if (!app || !existsSync(join(app, "package.json")) ||
|
|
75
|
+
(packages && !packages.some((p) => p.absolute === app))) {
|
|
76
|
+
errors.push(`${label}: appPath must name an existing workspace package`);
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
const html = read(app, host.html);
|
|
80
|
+
const entry = read(app, host.entry);
|
|
81
|
+
if (html === null) errors.push(`${label}: declared HTML file is missing or unreadable`);
|
|
82
|
+
if (entry === null) errors.push(`${label}: declared module entry is missing or unreadable`);
|
|
83
|
+
if (expected === null) errors.push(`${label}: emitted theme-bootstrap.js is missing or unreadable`);
|
|
84
|
+
if (html === null || entry === null || expected === null) continue;
|
|
85
|
+
const elements = htmlElements(html);
|
|
86
|
+
if (!elements.some((n) => n.name === "head" && !n.inert)) {
|
|
87
|
+
errors.push(`${label}: HTML has no head element`);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const headNodes = elements.filter((n) => n.parent === "head" && !n.inert);
|
|
91
|
+
const inline = headNodes.find((n) => n.name === "script" &&
|
|
92
|
+
!["src", "type", "defer", "async", "nomodule"].some((attr) => n.attrs.has(attr)) &&
|
|
93
|
+
n.text.trim() === expected.trim());
|
|
94
|
+
if (!inline) errors.push(`${label}: head must contain the exact emitted bootstrap as an inline script`);
|
|
95
|
+
else {
|
|
96
|
+
const earlyResource = headNodes.some((n) => n.index < inline.index &&
|
|
97
|
+
(n.name === "style" ||
|
|
98
|
+
(n.name === "link" && n.attrs.get("rel")?.toLowerCase().split(/\s+/).includes("stylesheet")) ||
|
|
99
|
+
(n.name === "script" && n.attrs.has("src"))));
|
|
100
|
+
if (earlyResource) {
|
|
101
|
+
errors.push(`${label}: bootstrap must precede styles and external scripts in head`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const src = `/${host.entry?.replace(/^\/+/, "")}`;
|
|
105
|
+
const moduleTag = elements.some((n) => n.name === "script" && !n.inert &&
|
|
106
|
+
n.attrs.get("type")?.toLowerCase() === "module" && n.attrs.get("src") === src);
|
|
107
|
+
if (!moduleTag) errors.push(`${label}: HTML must load the declared entry as a module script`);
|
|
108
|
+
if (!hasThemeStartup(entry, systemPackage)) {
|
|
109
|
+
errors.push(`${label}: linked entry must import and immediately call startThemeChoice at module top level`);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return { checked: declared.length, errors };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function formatThemeHosts(result) {
|
|
116
|
+
return result.errors.length
|
|
117
|
+
? `theme hosts ${result.checked} declared, ${result.errors.length} error(s):\n${result.errors.map((e) => ` ${e}`).join("\n")}`
|
|
118
|
+
: `theme hosts ${result.checked} declared, checked`;
|
|
119
|
+
}
|