@weatherboard/gyde-design 0.4.2 → 0.4.4
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 +82 -0
- package/agentdocs.mjs +11 -0
- package/cli.mjs +77 -1
- package/floor.mjs +479 -0
- package/htmlscan.mjs +149 -0
- package/index.mjs +1 -0
- package/package.json +5 -1
- package/theme-entry.mjs +34 -0
- package/themehost.mjs +85 -0
- package/vendor/README.md +43 -0
- package/vendor/acorn.mjs +6313 -0
package/theme-entry.mjs
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Parse the linked JavaScript module before accepting theme startup wiring. */
|
|
2
|
+
// Vendored rather than depended on: `action.yml` installs nothing, so every
|
|
3
|
+
// module it reaches may import only node: builtins and files inside the
|
|
4
|
+
// action path. G-138 is what happened when that was a comment. See
|
|
5
|
+
// vendor/README.md, and selfcontained.test.mjs for the guard.
|
|
6
|
+
import { parse } from "./vendor/acorn.mjs";
|
|
7
|
+
|
|
8
|
+
const directCall = (node, binding) => node?.type === "CallExpression" &&
|
|
9
|
+
node.callee.type === "Identifier" && node.callee.name === binding &&
|
|
10
|
+
node.arguments.length === 0 && !node.optional;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Only an actual named import followed by a direct top-level call counts.
|
|
14
|
+
* Acorn's module AST excludes comments, strings, template literals, regex
|
|
15
|
+
* literals, branches and callbacks from this path. A syntax error is a failed
|
|
16
|
+
* static contract rather than a guess that source-looking text will execute.
|
|
17
|
+
*/
|
|
18
|
+
export function hasThemeStartup(source, systemPackage) {
|
|
19
|
+
let program;
|
|
20
|
+
try { program = parse(source, { ecmaVersion: "latest", sourceType: "module" }); }
|
|
21
|
+
catch { return false; }
|
|
22
|
+
for (const [index, node] of program.body.entries()) {
|
|
23
|
+
if (node.type !== "ImportDeclaration" ||
|
|
24
|
+
node.source.value !== `${systemPackage}/theme-choice`) continue;
|
|
25
|
+
const binding = node.specifiers.find((part) => part.type === "ImportSpecifier" &&
|
|
26
|
+
part.imported.type === "Identifier" && part.imported.name === "startThemeChoice")?.local.name;
|
|
27
|
+
if (!binding) continue;
|
|
28
|
+
const next = program.body[index + 1];
|
|
29
|
+
if (next?.type === "ExpressionStatement" && directCall(next.expression, binding)) return true;
|
|
30
|
+
if (next?.type === "VariableDeclaration" && next.kind === "const" &&
|
|
31
|
+
next.declarations.length === 1 && directCall(next.declarations[0].init, binding)) return true;
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
package/themehost.mjs
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
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 { htmlElements } from "./htmlscan.mjs";
|
|
5
|
+
import { hasThemeStartup } from "./theme-entry.mjs";
|
|
6
|
+
|
|
7
|
+
function within(root, path) {
|
|
8
|
+
if (typeof path !== "string" || !path || path.startsWith("/")) return null;
|
|
9
|
+
const full = resolve(root, path);
|
|
10
|
+
const rel = relative(root, full);
|
|
11
|
+
return rel && rel !== ".." && !rel.startsWith(`..${sep}`) ? full : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function read(root, path) {
|
|
15
|
+
const full = within(root, path);
|
|
16
|
+
if (!full || !existsSync(full)) return null;
|
|
17
|
+
try { return readFileSync(full, "utf8"); } catch { return null; }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* `html-inline` is intentionally narrow: it proves concrete wiring in a
|
|
22
|
+
* standard HTML entry and its directly linked module. Framework layouts need
|
|
23
|
+
* their own adapter; a text marker in config is never accepted as evidence.
|
|
24
|
+
*/
|
|
25
|
+
export function checkThemeHosts(root, design = {}, { packages = null } = {}) {
|
|
26
|
+
const declared = design.themeHosts;
|
|
27
|
+
if (declared === undefined) return { checked: 0, errors: [] };
|
|
28
|
+
if (!Array.isArray(declared)) return { checked: 0, errors: ["design.themeHosts must be an array"] };
|
|
29
|
+
const errors = [];
|
|
30
|
+
const systemPath = design.systemPath || "packages/design-system";
|
|
31
|
+
const systemPackage = design.systemPackage || `${design.scope}/design-system`;
|
|
32
|
+
const expected = read(root, `${systemPath}/src/theme-bootstrap.js`);
|
|
33
|
+
for (const [index, host] of declared.entries()) {
|
|
34
|
+
const label = `design.themeHosts[${index}]`;
|
|
35
|
+
if (!host || typeof host !== "object" || host.adapter !== "html-inline") {
|
|
36
|
+
errors.push(`${label}: unsupported adapter; this gate currently supports html-inline`);
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const app = within(root, host.appPath);
|
|
40
|
+
if (!app || !existsSync(join(app, "package.json")) ||
|
|
41
|
+
(packages && !packages.some((p) => p.absolute === app))) {
|
|
42
|
+
errors.push(`${label}: appPath must name an existing workspace package`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const html = read(app, host.html);
|
|
46
|
+
const entry = read(app, host.entry);
|
|
47
|
+
if (html === null) errors.push(`${label}: declared HTML file is missing or unreadable`);
|
|
48
|
+
if (entry === null) errors.push(`${label}: declared module entry is missing or unreadable`);
|
|
49
|
+
if (expected === null) errors.push(`${label}: emitted theme-bootstrap.js is missing or unreadable`);
|
|
50
|
+
if (html === null || entry === null || expected === null) continue;
|
|
51
|
+
const elements = htmlElements(html);
|
|
52
|
+
if (!elements.some((n) => n.name === "head")) {
|
|
53
|
+
errors.push(`${label}: HTML has no head element`);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const headNodes = elements.filter((n) => n.parent === "head");
|
|
57
|
+
const inline = headNodes.find((n) => n.name === "script" &&
|
|
58
|
+
!["src", "type", "defer", "async", "nomodule"].some((attr) => n.attrs.has(attr)) &&
|
|
59
|
+
n.text.trim() === expected.trim());
|
|
60
|
+
if (!inline) errors.push(`${label}: head must contain the exact emitted bootstrap as an inline script`);
|
|
61
|
+
else {
|
|
62
|
+
const earlyResource = headNodes.some((n) => n.index < inline.index &&
|
|
63
|
+
(n.name === "style" ||
|
|
64
|
+
(n.name === "link" && n.attrs.get("rel")?.toLowerCase().split(/\s+/).includes("stylesheet")) ||
|
|
65
|
+
(n.name === "script" && n.attrs.has("src"))));
|
|
66
|
+
if (earlyResource) {
|
|
67
|
+
errors.push(`${label}: bootstrap must precede styles and external scripts in head`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const src = `/${host.entry?.replace(/^\/+/, "")}`;
|
|
71
|
+
const moduleTag = elements.some((n) => n.name === "script" &&
|
|
72
|
+
n.attrs.get("type")?.toLowerCase() === "module" && n.attrs.get("src") === src);
|
|
73
|
+
if (!moduleTag) errors.push(`${label}: HTML must load the declared entry as a module script`);
|
|
74
|
+
if (!hasThemeStartup(entry, systemPackage)) {
|
|
75
|
+
errors.push(`${label}: linked entry must import and immediately call startThemeChoice at module top level`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { checked: declared.length, errors };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function formatThemeHosts(result) {
|
|
82
|
+
return result.errors.length
|
|
83
|
+
? `theme hosts ${result.checked} declared, ${result.errors.length} error(s):\n${result.errors.map((e) => ` ${e}`).join("\n")}`
|
|
84
|
+
: `theme hosts ${result.checked} declared, checked`;
|
|
85
|
+
}
|
package/vendor/README.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Vendored third-party source
|
|
2
|
+
|
|
3
|
+
Files here are **copied verbatim from an npm package** and imported by relative
|
|
4
|
+
path. They are not edited, ever. `selfcontained.test.mjs` asserts each one is
|
|
5
|
+
byte-identical to the installed package it came from, so a hand edit or a stale
|
|
6
|
+
copy is a failing gate rather than a divergence nobody can see.
|
|
7
|
+
|
|
8
|
+
## Why anything is vendored at all
|
|
9
|
+
|
|
10
|
+
`action.yml` runs `packages/design/cli.mjs` straight out of the action checkout
|
|
11
|
+
and **installs nothing** — no network call, no lockfile, no registry credential
|
|
12
|
+
in a consumer's CI. That is the whole of G-63, and it means every module the
|
|
13
|
+
action reaches may import `node:` builtins and files inside the action path,
|
|
14
|
+
and nothing else.
|
|
15
|
+
|
|
16
|
+
G-138 is what happens when that is stated in a comment and enforced by nothing:
|
|
17
|
+
`themehost.mjs` imported `parse5` and `theme-entry.mjs` imported `acorn`, the
|
|
18
|
+
`v1` tag moved, and every consumer's `design-system` check went red with
|
|
19
|
+
`ERR_MODULE_NOT_FOUND` — in a repository whose own CI installs both and was
|
|
20
|
+
therefore green. `selfcontained.test.mjs` is the guard that would have caught
|
|
21
|
+
it; this directory is how a genuinely needed package is carried.
|
|
22
|
+
|
|
23
|
+
## Why acorn, and why not parse5
|
|
24
|
+
|
|
25
|
+
Vendoring is the second choice. The first is not needing the package.
|
|
26
|
+
|
|
27
|
+
`parse5`'s job in `themehost.mjs` was narrow and fully pinned by
|
|
28
|
+
`themehost.test.mjs`, and parse5 v8 is a fifteen-file ESM tree with its own
|
|
29
|
+
`entities` dependency — carrying it means carrying a dependency tree by hand.
|
|
30
|
+
So it was **dropped** for `../htmlscan.mjs`, 120 lines of our own code.
|
|
31
|
+
|
|
32
|
+
`acorn` parses arbitrary JavaScript, which must not be hand-rolled: the gate's
|
|
33
|
+
whole claim is that a call inside a string, a template, a regex or a dead
|
|
34
|
+
branch is not startup wiring, and that claim is only as good as the parser. It
|
|
35
|
+
ships `dist/acorn.mjs` as a **single self-contained ESM file with no
|
|
36
|
+
dependencies of its own**, so carrying it is one file and one equality check.
|
|
37
|
+
|
|
38
|
+
| file | package | from |
|
|
39
|
+
| --- | --- | --- |
|
|
40
|
+
| `acorn.mjs` | `acorn` | `node_modules/acorn/dist/acorn.mjs` |
|
|
41
|
+
|
|
42
|
+
To update: change the version in the root `package.json`, `npm install`, copy
|
|
43
|
+
the file across, and run `npm run verify`.
|