@weatherboard/gyde-design 0.4.2 → 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 CHANGED
@@ -214,6 +214,17 @@ The design-system package supplies \`src/theme-bootstrap.js\` and exports
214
214
  replacement and repairs external attribute changes. Keep local route theme
215
215
  overrides on inner containers so they do not overwrite the global choice.
216
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
+
217
228
  The storage key is scoped to this design-system package. \`system\` is an
218
229
  explicit stored choice that removes \`data-theme\` from the document root and
219
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weatherboard/gyde-design",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "private": false,
5
5
  "description": "Scaffolds a design system into a product repository, then keeps auditing it.",
6
6
  "type": "module",
@@ -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
+ }