@homepages/template-kit 0.1.0 → 0.3.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/CHANGELOG.md +220 -0
- package/README.md +70 -16
- package/dist/base.css +36 -48
- package/dist/cli/check/config.js +37 -0
- package/dist/cli/check/css.js +117 -0
- package/dist/cli/check/diagnostics.js +32 -0
- package/dist/cli/check/index.js +87 -0
- package/dist/cli/check/loader.js +142 -0
- package/dist/cli/check/paths.js +15 -0
- package/dist/cli/check/relativize.js +18 -0
- package/dist/cli/check/render-invariants.js +24 -0
- package/dist/cli/check/resolve-tool.js +38 -0
- package/dist/cli/check/stages/deps.js +337 -0
- package/dist/cli/check/stages/lint.js +46 -0
- package/dist/cli/check/stages/render.js +101 -0
- package/dist/cli/check/stages/size.js +158 -0
- package/dist/cli/check/stages/tree.js +207 -0
- package/dist/cli/check/stages/typecheck.js +46 -0
- package/dist/cli/check/stages/validate/catalog-exhaustiveness.js +18 -0
- package/dist/cli/check/stages/validate/facts.js +18 -0
- package/dist/cli/check/stages/validate/formatter-contract.js +14 -0
- package/dist/cli/check/stages/validate/group-contract.js +21 -0
- package/dist/cli/check/stages/validate/index.js +66 -0
- package/dist/cli/check/stages/validate/list-scalar-contract.js +22 -0
- package/dist/cli/check/stages/validate/nav-contract.js +93 -0
- package/dist/cli/check/stages/validate/nested-slot-contract.js +12 -0
- package/dist/cli/check/stages/validate/object-list-contract.js +31 -0
- package/dist/cli/check/stages/validate/orchestrator.js +241 -0
- package/dist/cli/check/stages/validate/produced-by-contract.js +18 -0
- package/dist/cli/check/stages/validate/row-contract.js +31 -0
- package/dist/cli/check/stages/validate/select-contract.js +13 -0
- package/dist/cli/check/stages/validate/source-contract.js +21 -0
- package/dist/cli/check/stages/validate/variant-contract.js +15 -0
- package/dist/cli/check/workspace.js +86 -0
- package/dist/cli/theme/generate.js +43 -0
- package/dist/cli/theme/index.js +33 -0
- package/dist/cli/theme/load-theme.js +69 -0
- package/dist/cli.js +22 -4
- package/dist/contracts/fill-treatments.js +47 -0
- package/dist/design-system/theme.d.ts +30 -299
- package/dist/design-system/theme.js +112 -83
- package/dist/eslint/rules/image-bare-needs-reason.js +6 -1
- package/dist/eslint/rules/no-client-directive-in-contract.js +6 -1
- package/dist/eslint/rules/no-client-runtime-in-server.js +5 -1
- package/dist/eslint/rules/no-css-import-from-render-path.js +8 -2
- package/dist/eslint/rules/no-hex.js +84 -7
- package/dist/eslint/rules/no-inline-style.js +5 -1
- package/dist/eslint/rules/no-nondeterminism.js +5 -1
- package/dist/eslint/rules/no-raw-element.js +6 -1
- package/dist/eslint/rules/props-from-schema.js +6 -1
- package/dist/eslint/rules/serializable-island-props.js +5 -1
- package/dist/eslint/rules/slot-marker-literal.js +6 -1
- package/dist/eslint.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/package.js +1 -1
- package/dist/primitives/Image.js +1 -1
- package/dist/rules/registry.js +16 -0
- package/dist/schema/fill-spec.d.ts +80 -590
- package/dist/schema/fixture-schema.d.ts +5 -81
- package/dist/schema/manifest.d.ts +39 -475
- package/dist/schema/resolve-section-ref.js +10 -0
- package/dist/schema/rows.js +10 -0
- package/dist/schema/section-nav.js +9 -0
- package/dist/schema/section-schema.d.ts +51 -437
- package/dist/schema/slot-types.d.ts +12 -16
- package/dist/styles.css +31 -88
- package/docs/INDEX.md +5 -3
- package/docs/check.md +121 -0
- package/docs/eslint.md +25 -50
- package/docs/llms.txt +26 -11
- package/docs/primitives.md +5 -0
- package/docs/rules/INDEX.md +65 -0
- package/docs/rules/audit-severity.md +91 -0
- package/docs/rules/bundle-binary-asset.md +102 -0
- package/docs/rules/bundle-incomplete.md +117 -0
- package/docs/rules/css-reason.md +94 -0
- package/docs/rules/determinism-drift.md +112 -0
- package/docs/rules/fixtures-invalid.md +96 -0
- package/docs/rules/image-bare-needs-reason.md +117 -0
- package/docs/rules/license-denied.md +114 -0
- package/docs/rules/lockfile-missing.md +68 -0
- package/docs/rules/lockfile-stale.md +88 -0
- package/docs/rules/manifest-invalid.md +99 -0
- package/docs/rules/missing-slot-marker.md +140 -0
- package/docs/rules/no-bare-css-import.md +128 -0
- package/docs/rules/no-client-directive-in-contract.md +111 -0
- package/docs/rules/no-client-runtime-in-server.md +143 -0
- package/docs/rules/no-css-import-from-render-path.md +118 -0
- package/docs/rules/no-hex.md +163 -0
- package/docs/rules/no-inline-style.md +121 -0
- package/docs/rules/no-nondeterminism.md +100 -0
- package/docs/rules/no-raw-element.md +100 -0
- package/docs/rules/no-templates.md +87 -0
- package/docs/rules/parse-error.md +76 -0
- package/docs/rules/props-from-schema.md +119 -0
- package/docs/rules/render-invariant.md +165 -0
- package/docs/rules/schema-invalid.md +96 -0
- package/docs/rules/serializable-island-props.md +146 -0
- package/docs/rules/server-vs-client.md +105 -0
- package/docs/rules/sidebar-order.md +109 -0
- package/docs/rules/single-react.md +97 -0
- package/docs/rules/size-renderer-bundle.md +109 -0
- package/docs/rules/size-section-css.md +95 -0
- package/docs/rules/slot-group-marker.md +127 -0
- package/docs/rules/slot-marker-literal.md +148 -0
- package/docs/rules/typecheck.md +103 -0
- package/docs/rules/unlayered-fence.md +113 -0
- package/docs/theme-and-css.md +193 -82
- package/package.json +4 -9
- package/tsconfig.json +1 -1
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { KIT_VERSION } from "../../index.js";
|
|
2
|
+
import { formatHuman, toJson } from "./diagnostics.js";
|
|
3
|
+
import { loadEsbuild } from "./resolve-tool.js";
|
|
4
|
+
import { resetLoadCache } from "./loader.js";
|
|
5
|
+
import { depsStage } from "./stages/deps.js";
|
|
6
|
+
import { lintStage } from "./stages/lint.js";
|
|
7
|
+
import { renderStage } from "./stages/render.js";
|
|
8
|
+
import { sizeStage } from "./stages/size.js";
|
|
9
|
+
import { treeStage } from "./stages/tree.js";
|
|
10
|
+
import { typecheckStage } from "./stages/typecheck.js";
|
|
11
|
+
import { validateStage } from "./stages/validate/index.js";
|
|
12
|
+
import { findWorkspaceRoot, selectTemplates, templateFromCwd } from "./workspace.js";
|
|
13
|
+
import { parseArgs } from "node:util";
|
|
14
|
+
|
|
15
|
+
//#region src/cli/check/index.ts
|
|
16
|
+
const STAGES = [
|
|
17
|
+
typecheckStage,
|
|
18
|
+
lintStage,
|
|
19
|
+
validateStage,
|
|
20
|
+
treeStage,
|
|
21
|
+
renderStage,
|
|
22
|
+
sizeStage
|
|
23
|
+
];
|
|
24
|
+
/**
|
|
25
|
+
* A run that gated nothing must never report success. `every()` over an empty set
|
|
26
|
+
* is vacuously true, so a selection of zero templates printed "check passed" and
|
|
27
|
+
* exited 0 having run not one gate — the worst failure this command has, since
|
|
28
|
+
* ingest re-runs it and would accept the submission on that same silence.
|
|
29
|
+
*/
|
|
30
|
+
function emptySelectionDiagnostics(selected, all) {
|
|
31
|
+
if (selected.length > 0) return [];
|
|
32
|
+
return [{
|
|
33
|
+
ruleId: "template-kit/no-templates",
|
|
34
|
+
template: "",
|
|
35
|
+
file: "templates",
|
|
36
|
+
message: all ? "check --all found no template to check: templates/ holds no template folder." : "This workspace has no template to check: templates/ holds no template folder.",
|
|
37
|
+
fix: "Add a template: templates/<key>/ with a manifest.json declaring its section composition and a sections/ folder holding the sections it composes."
|
|
38
|
+
}];
|
|
39
|
+
}
|
|
40
|
+
async function checkTemplate(root, template) {
|
|
41
|
+
const diagnostics = [];
|
|
42
|
+
for (const stage of STAGES) diagnostics.push(...await stage(root, template));
|
|
43
|
+
return {
|
|
44
|
+
ok: diagnostics.length === 0,
|
|
45
|
+
diagnostics
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
async function runCheck(argv, cwd) {
|
|
49
|
+
resetLoadCache();
|
|
50
|
+
const { values, positionals } = parseArgs({
|
|
51
|
+
args: argv,
|
|
52
|
+
options: {
|
|
53
|
+
all: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
default: false
|
|
56
|
+
},
|
|
57
|
+
json: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: false
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
allowPositionals: true
|
|
63
|
+
});
|
|
64
|
+
const root = await findWorkspaceRoot(cwd);
|
|
65
|
+
const selected = await selectTemplates(root, positionals[0] ?? (values.all ? void 0 : templateFromCwd(root, cwd)), values.all, "check");
|
|
66
|
+
if (selected.length > 0) await loadEsbuild(root);
|
|
67
|
+
const templates = {};
|
|
68
|
+
for (const template of selected) templates[template.key] = await checkTemplate(root, template);
|
|
69
|
+
const workspaceDiagnostics = [...await depsStage(root), ...emptySelectionDiagnostics(selected, values.all)];
|
|
70
|
+
const workspace = {
|
|
71
|
+
ok: workspaceDiagnostics.length === 0,
|
|
72
|
+
diagnostics: workspaceDiagnostics
|
|
73
|
+
};
|
|
74
|
+
const report = {
|
|
75
|
+
ok: workspace.ok && Object.values(templates).every((t) => t.ok),
|
|
76
|
+
kitVersion: KIT_VERSION,
|
|
77
|
+
templates,
|
|
78
|
+
workspace
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
code: report.ok ? 0 : 1,
|
|
82
|
+
output: values.json ? toJson(report) : formatHuman(report)
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
//#endregion
|
|
87
|
+
export { runCheck };
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { loadEsbuild } from "./resolve-tool.js";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { mkdir, rm, stat } from "node:fs/promises";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
|
|
7
|
+
//#region src/cli/check/loader.ts
|
|
8
|
+
const CONTRACT_FILES = [
|
|
9
|
+
"Renderer.tsx",
|
|
10
|
+
"schema.ts",
|
|
11
|
+
"fill-spec.ts",
|
|
12
|
+
"fixtures.ts"
|
|
13
|
+
];
|
|
14
|
+
const EXPECTED_EXPORT = {
|
|
15
|
+
"Renderer.tsx": "default",
|
|
16
|
+
"schema.ts": "schema",
|
|
17
|
+
"fill-spec.ts": "fillSpec",
|
|
18
|
+
"fixtures.ts": "default"
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* A section is missing one or more of the four contract files. Typed (not a bare
|
|
22
|
+
* Error) because exactly one stage owns this failure: the tree stage's
|
|
23
|
+
* `bundle-incomplete`, which names the missing file and where it belongs. Every
|
|
24
|
+
* other stage swallows this cause rather than re-reporting the same root problem
|
|
25
|
+
* under a second rule id.
|
|
26
|
+
*/
|
|
27
|
+
var MissingContractFile = class extends Error {
|
|
28
|
+
files;
|
|
29
|
+
constructor(files) {
|
|
30
|
+
super(`Section is missing its contract file(s): ${files.join(", ")}.`);
|
|
31
|
+
this.files = files;
|
|
32
|
+
this.name = "MissingContractFile";
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
/** A contract file built, but does not export the binding the contract requires. `file` is workspace-relative. */
|
|
36
|
+
var MissingSectionExport = class extends Error {
|
|
37
|
+
file;
|
|
38
|
+
binding;
|
|
39
|
+
constructor(file, binding) {
|
|
40
|
+
super(binding === "default" ? `${file} has no default export. It must export the section's ${file.endsWith("Renderer.tsx") ? "renderer component" : "fixture module"} as its default export.` : `${file} does not export "${binding}". It must export a "${binding}" binding.`);
|
|
41
|
+
this.file = file;
|
|
42
|
+
this.binding = binding;
|
|
43
|
+
this.name = "MissingSectionExport";
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
const RESOLVING = Symbol("css-stub:resolving");
|
|
47
|
+
const cssStub = {
|
|
48
|
+
name: "css-stub",
|
|
49
|
+
setup(b) {
|
|
50
|
+
b.onResolve({ filter: /.*/ }, async (args) => {
|
|
51
|
+
if (args.pluginData === RESOLVING) return null;
|
|
52
|
+
const resolved = await b.resolve(args.path, {
|
|
53
|
+
importer: args.importer,
|
|
54
|
+
namespace: args.namespace,
|
|
55
|
+
resolveDir: args.resolveDir,
|
|
56
|
+
kind: args.kind,
|
|
57
|
+
pluginData: RESOLVING
|
|
58
|
+
});
|
|
59
|
+
if (resolved.errors.length === 0 && resolved.path !== "" && resolved.path.endsWith(".css")) return {
|
|
60
|
+
path: resolved.path,
|
|
61
|
+
namespace: "css-stub"
|
|
62
|
+
};
|
|
63
|
+
if (!args.path.startsWith(".") && !args.path.startsWith("/")) return {
|
|
64
|
+
path: args.path,
|
|
65
|
+
external: true
|
|
66
|
+
};
|
|
67
|
+
return null;
|
|
68
|
+
});
|
|
69
|
+
b.onLoad({
|
|
70
|
+
filter: /.*/,
|
|
71
|
+
namespace: "css-stub"
|
|
72
|
+
}, () => ({
|
|
73
|
+
contents: "",
|
|
74
|
+
loader: "js"
|
|
75
|
+
}));
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
const loadCache = /* @__PURE__ */ new Map();
|
|
79
|
+
/** Drops every memoized section load. Called once per `runCheck`; see the note above. */
|
|
80
|
+
function resetLoadCache() {
|
|
81
|
+
loadCache.clear();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Transpile + evaluate a section's four contract modules. Throws `MissingContractFile`
|
|
85
|
+
* when one of the four is absent, `MissingSectionExport` when one built but does not
|
|
86
|
+
* export its required binding, and a plain `Error` (esbuild's, path-relativized by the
|
|
87
|
+
* caller) when one fails to build.
|
|
88
|
+
*/
|
|
89
|
+
function loadSection(workspaceRoot, section) {
|
|
90
|
+
const outdir = join(workspaceRoot, "node_modules", ".template-kit", "check", createHash("sha256").update(section.dir).digest("hex").slice(0, 12));
|
|
91
|
+
const cached = loadCache.get(outdir);
|
|
92
|
+
if (cached) return cached;
|
|
93
|
+
const promise = buildAndLoad(section, outdir, workspaceRoot);
|
|
94
|
+
loadCache.set(outdir, promise);
|
|
95
|
+
return promise;
|
|
96
|
+
}
|
|
97
|
+
const isFile = async (p) => {
|
|
98
|
+
try {
|
|
99
|
+
return (await stat(p)).isFile();
|
|
100
|
+
} catch {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
async function buildAndLoad(section, outdir, workspaceRoot) {
|
|
105
|
+
const missing = [];
|
|
106
|
+
for (const file of CONTRACT_FILES) if (!await isFile(join(section.dir, file))) missing.push(file);
|
|
107
|
+
if (missing.length > 0) throw new MissingContractFile(missing);
|
|
108
|
+
await rm(outdir, {
|
|
109
|
+
recursive: true,
|
|
110
|
+
force: true
|
|
111
|
+
});
|
|
112
|
+
await mkdir(outdir, { recursive: true });
|
|
113
|
+
const { build } = await loadEsbuild(workspaceRoot);
|
|
114
|
+
await build({
|
|
115
|
+
entryPoints: CONTRACT_FILES.map((f) => join(section.dir, f)),
|
|
116
|
+
outdir,
|
|
117
|
+
bundle: true,
|
|
118
|
+
splitting: true,
|
|
119
|
+
format: "esm",
|
|
120
|
+
platform: "neutral",
|
|
121
|
+
jsx: "automatic",
|
|
122
|
+
logLevel: "silent",
|
|
123
|
+
plugins: [cssStub],
|
|
124
|
+
absWorkingDir: workspaceRoot
|
|
125
|
+
});
|
|
126
|
+
const load = async (source, emitted) => {
|
|
127
|
+
const mod = await import(pathToFileURL(join(outdir, emitted)).href);
|
|
128
|
+
const binding = EXPECTED_EXPORT[source];
|
|
129
|
+
const value = mod[binding];
|
|
130
|
+
if (value === void 0) throw new MissingSectionExport(relative(workspaceRoot, join(section.dir, source)), binding);
|
|
131
|
+
return value;
|
|
132
|
+
};
|
|
133
|
+
return {
|
|
134
|
+
Renderer: await load("Renderer.tsx", "Renderer.js"),
|
|
135
|
+
schema: await load("schema.ts", "schema.js"),
|
|
136
|
+
fillSpec: await load("fill-spec.ts", "fill-spec.js"),
|
|
137
|
+
fixtures: await load("fixtures.ts", "fixtures.js")
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
//#endregion
|
|
142
|
+
export { CONTRACT_FILES, MissingContractFile, MissingSectionExport, cssStub, loadSection, resetLoadCache };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { relative, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region src/cli/check/paths.ts
|
|
4
|
+
/** `file` is `dir` itself or nested under it. */
|
|
5
|
+
function isUnder(dir, file) {
|
|
6
|
+
const rel = relative(dir, file);
|
|
7
|
+
return rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`);
|
|
8
|
+
}
|
|
9
|
+
/** The section `file` sits inside, or `undefined` when it isn't under any of the template's sections. */
|
|
10
|
+
function sectionOf(template, file) {
|
|
11
|
+
return template.sections.find((section) => isUnder(section.dir, file))?.name;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
//#endregion
|
|
15
|
+
export { isUnder, sectionOf };
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { resolve, sep } from "node:path";
|
|
2
|
+
|
|
3
|
+
//#region src/cli/check/relativize.ts
|
|
4
|
+
function escapeRegExp(s) {
|
|
5
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Rewrites every occurrence of `workspaceRoot` in a tool-sourced message to the
|
|
9
|
+
* path an author would type: workspace-relative, or "." for the root itself.
|
|
10
|
+
* Idempotent, and a no-op on a message that names no path.
|
|
11
|
+
*/
|
|
12
|
+
function relativizePaths(message, workspaceRoot) {
|
|
13
|
+
const root = resolve(workspaceRoot);
|
|
14
|
+
return message.replace(new RegExp(`${escapeRegExp(root)}${escapeRegExp(sep)}`, "g"), "").replace(new RegExp(escapeRegExp(root), "g"), ".");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
export { relativizePaths };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
//#region src/cli/check/render-invariants.ts
|
|
2
|
+
function checkRenderInvariants(html) {
|
|
3
|
+
const violations = [];
|
|
4
|
+
if (html.includes("[object Object]")) violations.push({
|
|
5
|
+
rule: "sentinel-text",
|
|
6
|
+
detail: "rendered text contains \"[object Object]\""
|
|
7
|
+
});
|
|
8
|
+
for (const m of html.matchAll(/>\s*(null|undefined|NaN)\s*</g)) violations.push({
|
|
9
|
+
rule: "sentinel-text",
|
|
10
|
+
detail: `rendered leaf is the bare sentinel "${m[1]}"`
|
|
11
|
+
});
|
|
12
|
+
for (const m of html.matchAll(/<img\b[^>]*>/g)) {
|
|
13
|
+
const attr = /\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)')/.exec(m[0]);
|
|
14
|
+
const src = attr ? attr[1] ?? attr[2] : void 0;
|
|
15
|
+
if (src === void 0 || src.trim() === "" || src === "undefined" || src === "null") violations.push({
|
|
16
|
+
rule: "broken-image",
|
|
17
|
+
detail: `<img> with ${src === void 0 ? "no src" : `src="${src}"`}`
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
return violations;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
export { checkRenderInvariants };
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { pathToFileURL } from "node:url";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
|
|
5
|
+
//#region src/cli/check/resolve-tool.ts
|
|
6
|
+
/**
|
|
7
|
+
* Loads `specifier` exactly as code running in the workspace at `root` would:
|
|
8
|
+
* resolved from that workspace's own `package.json`, never from this
|
|
9
|
+
* package's dependency tree. A missing install is a clean, actionable
|
|
10
|
+
* `Error` — never a bare `MODULE_NOT_FOUND` stack trace.
|
|
11
|
+
*/
|
|
12
|
+
async function resolveFromWorkspace(root, specifier) {
|
|
13
|
+
const require = createRequire(pathToFileURL(join(root, "package.json")));
|
|
14
|
+
let resolved;
|
|
15
|
+
try {
|
|
16
|
+
resolved = require.resolve(specifier);
|
|
17
|
+
} catch {
|
|
18
|
+
throw new Error(`${specifier} is not installed in this workspace. Install it: npm i -D ${specifier}`);
|
|
19
|
+
}
|
|
20
|
+
return await import(pathToFileURL(resolved).href);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The workspace's esbuild — `check` transpiles a section's contract modules
|
|
24
|
+
* (loader.ts), bundles its package CSS (css.ts) and measures its published bytes
|
|
25
|
+
* (stages/size.ts) with it.
|
|
26
|
+
*
|
|
27
|
+
* `typeof import("esbuild")` is a TYPE-only import: it is erased at build time, so
|
|
28
|
+
* this module never carries a static `esbuild` import into the shipped `dist/`, and
|
|
29
|
+
* a consumer that only renders sections never installs it. Every call site must
|
|
30
|
+
* `await` this rather than importing `build` at module scope — a static import would
|
|
31
|
+
* put esbuild back on the critical path of `import "@homepages/template-kit"`.
|
|
32
|
+
*/
|
|
33
|
+
function loadEsbuild(root) {
|
|
34
|
+
return resolveFromWorkspace(root, "esbuild");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
//#endregion
|
|
38
|
+
export { loadEsbuild, resolveFromWorkspace };
|
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
import { CHECK_CONFIG } from "../config.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { promisify } from "node:util";
|
|
5
|
+
import { readFile, readdir, realpath, stat } from "node:fs/promises";
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
|
|
8
|
+
//#region src/cli/check/stages/deps.ts
|
|
9
|
+
const execFileAsync = promisify(execFile);
|
|
10
|
+
/** Real `execFile`-backed runner. Never throws on a non-zero exit — `npm audit`
|
|
11
|
+
* and `npm ls` both use exit codes to report *findings*, not tool failure, so
|
|
12
|
+
* the caller must always get the payload to parse. */
|
|
13
|
+
const defaultRun = async (cmd, args, cwd) => {
|
|
14
|
+
try {
|
|
15
|
+
const { stdout } = await execFileAsync(cmd, args, {
|
|
16
|
+
cwd,
|
|
17
|
+
maxBuffer: 64 * 1024 * 1024
|
|
18
|
+
});
|
|
19
|
+
return {
|
|
20
|
+
stdout,
|
|
21
|
+
code: 0
|
|
22
|
+
};
|
|
23
|
+
} catch (err) {
|
|
24
|
+
const e = err;
|
|
25
|
+
return {
|
|
26
|
+
stdout: typeof e.stdout === "string" ? e.stdout : "",
|
|
27
|
+
code: typeof e.code === "number" ? e.code : 1
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
async function isFile(p) {
|
|
32
|
+
try {
|
|
33
|
+
return (await stat(p)).isFile();
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function isDir(p) {
|
|
39
|
+
try {
|
|
40
|
+
return (await stat(p)).isDirectory();
|
|
41
|
+
} catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** `fs.realpath`, or `undefined` if the path is missing or unresolvable
|
|
46
|
+
* (e.g. a broken symlink or a genuine symlink loop, which trips `ELOOP`). */
|
|
47
|
+
async function realPathOrUndefined(p) {
|
|
48
|
+
try {
|
|
49
|
+
return await realpath(p);
|
|
50
|
+
} catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async function readJsonIfPresent(path) {
|
|
55
|
+
if (!await isFile(path)) return void 0;
|
|
56
|
+
const text = await readFile(path, "utf8");
|
|
57
|
+
return JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
const LOCKFILE_MISSING_FIX = "Run npm install and commit the package-lock.json it writes. A submission without one cannot be reproduced.";
|
|
60
|
+
function lockfileMissingGate(hasLockfile) {
|
|
61
|
+
if (hasLockfile) return [];
|
|
62
|
+
return [{
|
|
63
|
+
ruleId: "template-kit/lockfile-missing",
|
|
64
|
+
template: "",
|
|
65
|
+
message: "no package-lock.json at the workspace root.",
|
|
66
|
+
fix: LOCKFILE_MISSING_FIX
|
|
67
|
+
}];
|
|
68
|
+
}
|
|
69
|
+
const LOCKFILE_STALE_FIX = "The lockfile does not match package.json. Run npm install and commit the updated lockfile.";
|
|
70
|
+
const npmLsNodeSchema = z.lazy(() => z.object({
|
|
71
|
+
invalid: z.boolean().optional(),
|
|
72
|
+
dependencies: z.record(z.string(), npmLsNodeSchema).optional()
|
|
73
|
+
}).passthrough());
|
|
74
|
+
const npmLsPayloadSchema = z.object({
|
|
75
|
+
problems: z.array(z.unknown()).optional(),
|
|
76
|
+
dependencies: z.record(z.string(), npmLsNodeSchema).optional()
|
|
77
|
+
}).passthrough();
|
|
78
|
+
function anyInvalid(nodes) {
|
|
79
|
+
if (!nodes) return false;
|
|
80
|
+
for (const node of Object.values(nodes)) {
|
|
81
|
+
if (node.invalid === true) return true;
|
|
82
|
+
if (anyInvalid(node.dependencies)) return true;
|
|
83
|
+
}
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
async function lockfileStaleGate(run, workspaceRoot) {
|
|
87
|
+
const { stdout, code } = await run("npm", [
|
|
88
|
+
"ls",
|
|
89
|
+
"--json",
|
|
90
|
+
"--package-lock-only"
|
|
91
|
+
], workspaceRoot);
|
|
92
|
+
const parsed = npmLsPayloadSchema.safeParse(safeJsonParse(stdout));
|
|
93
|
+
if (!(code !== 0 || !parsed.success || (parsed.data.problems?.length ?? 0) > 0 || anyInvalid(parsed.data.dependencies))) return [];
|
|
94
|
+
return [{
|
|
95
|
+
ruleId: "template-kit/lockfile-stale",
|
|
96
|
+
template: "",
|
|
97
|
+
message: "the lockfile does not satisfy package.json — npm ci would fail.",
|
|
98
|
+
fix: LOCKFILE_STALE_FIX
|
|
99
|
+
}];
|
|
100
|
+
}
|
|
101
|
+
function safeJsonParse(text) {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(text);
|
|
104
|
+
} catch {
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const SINGLE_REACT_FIX = "Two copies of React break hydration. React is a peer of everything here — deduplicate with npm dedupe, or align the dependency that pins its own copy.";
|
|
109
|
+
const packageManifestSchema = z.object({ version: z.string().optional() }).passthrough();
|
|
110
|
+
async function readVersion(pkgDir) {
|
|
111
|
+
try {
|
|
112
|
+
const raw = await readJsonIfPresent(join(pkgDir, "package.json"));
|
|
113
|
+
const parsed = packageManifestSchema.safeParse(raw);
|
|
114
|
+
return parsed.success && parsed.data.version !== void 0 ? parsed.data.version : "unknown version";
|
|
115
|
+
} catch {
|
|
116
|
+
return "unknown version";
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** True when `entry` is worth descending into: a real directory, or a
|
|
120
|
+
* symlink whose target is one. `Dirent.isDirectory()` reports the dirent's
|
|
121
|
+
* OWN type, not what it resolves to — a symlink to a directory (exactly how
|
|
122
|
+
* `npm link` and npm/yarn workspaces lay out linked packages) reports
|
|
123
|
+
* `false`, so trusting it blindly makes the walk blind to every linked
|
|
124
|
+
* package's own `node_modules`. `isDir` re-`stat`s, which follows the link. */
|
|
125
|
+
async function isTraversableDir(dir, entry) {
|
|
126
|
+
if (entry.isDirectory()) return true;
|
|
127
|
+
if (!entry.isSymbolicLink()) return false;
|
|
128
|
+
return isDir(join(dir, entry.name));
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Walks `node_modules` for a nested `react` or `react-dom` — one whose own
|
|
132
|
+
* `node_modules` (not the workspace's top-level one) holds a copy. `owner` is
|
|
133
|
+
* the name of the package whose private `node_modules` directory holds the
|
|
134
|
+
* offending copy, so the message can point straight at what to dedupe.
|
|
135
|
+
*
|
|
136
|
+
* `topLevelReal` maps "react"/"react-dom" to the REAL (symlink-resolved)
|
|
137
|
+
* path of the workspace's own top-level copy, if any — see
|
|
138
|
+
* `visitReactCandidate` for why identity is checked against it rather than
|
|
139
|
+
* against walk depth. `visited` is the set of real directory paths already
|
|
140
|
+
* walked: a defense against a symlink cycle (a linked package whose own
|
|
141
|
+
* `node_modules` loops back to an ancestor already on the path) turning this
|
|
142
|
+
* into infinite recursion.
|
|
143
|
+
*/
|
|
144
|
+
async function walkForNestedReact(dir, atTopLevel, owner, out, topLevelReal, visited) {
|
|
145
|
+
const real = await realPathOrUndefined(dir);
|
|
146
|
+
if (real === void 0 || visited.has(real)) return;
|
|
147
|
+
visited.add(real);
|
|
148
|
+
let entries;
|
|
149
|
+
try {
|
|
150
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
151
|
+
} catch {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!await isTraversableDir(dir, entry)) continue;
|
|
156
|
+
if (entry.name.startsWith("@")) {
|
|
157
|
+
const scopeDir = join(dir, entry.name);
|
|
158
|
+
let scoped;
|
|
159
|
+
try {
|
|
160
|
+
scoped = await readdir(scopeDir, { withFileTypes: true });
|
|
161
|
+
} catch {
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
for (const inner of scoped) {
|
|
165
|
+
if (!await isTraversableDir(scopeDir, inner)) continue;
|
|
166
|
+
await visitReactCandidate(join(scopeDir, inner.name), `${entry.name}/${inner.name}`, atTopLevel, owner, out, topLevelReal, visited);
|
|
167
|
+
}
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
await visitReactCandidate(join(dir, entry.name), entry.name, atTopLevel, owner, out, topLevelReal, visited);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
async function visitReactCandidate(pkgDir, name, atTopLevel, owner, out, topLevelReal, visited) {
|
|
174
|
+
if (!atTopLevel && (name === "react" || name === "react-dom")) {
|
|
175
|
+
if ((await realPathOrUndefined(pkgDir) ?? pkgDir) !== topLevelReal.get(name)) out.push({
|
|
176
|
+
name,
|
|
177
|
+
version: await readVersion(pkgDir),
|
|
178
|
+
owner
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
const nested = join(pkgDir, "node_modules");
|
|
182
|
+
if (await isDir(nested)) await walkForNestedReact(nested, false, name, out, topLevelReal, visited);
|
|
183
|
+
}
|
|
184
|
+
async function singleReactGate(workspaceRoot) {
|
|
185
|
+
const nodeModules = join(workspaceRoot, "node_modules");
|
|
186
|
+
const offenders = [];
|
|
187
|
+
const topLevelReal = /* @__PURE__ */ new Map();
|
|
188
|
+
for (const name of ["react", "react-dom"]) {
|
|
189
|
+
const real = await realPathOrUndefined(join(nodeModules, name));
|
|
190
|
+
if (real !== void 0) topLevelReal.set(name, real);
|
|
191
|
+
}
|
|
192
|
+
await walkForNestedReact(nodeModules, true, "", offenders, topLevelReal, /* @__PURE__ */ new Set());
|
|
193
|
+
return offenders.map((offender) => ({
|
|
194
|
+
ruleId: "template-kit/single-react",
|
|
195
|
+
template: "",
|
|
196
|
+
message: `a nested copy of ${offender.name}@${offender.version} is bundled inside ${offender.owner}'s own node_modules.`,
|
|
197
|
+
fix: SINGLE_REACT_FIX
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
const AUDIT_SEVERITY_FIX = "Run npm audit fix, or upgrade the package the advisory names.";
|
|
201
|
+
const npmAuditPayloadSchema = z.object({ metadata: z.object({ vulnerabilities: z.record(z.string(), z.number()).optional() }).passthrough().optional() }).passthrough();
|
|
202
|
+
async function auditSeverityGate(run, workspaceRoot) {
|
|
203
|
+
const { stdout } = await run("npm", ["audit", "--json"], workspaceRoot);
|
|
204
|
+
const parsed = npmAuditPayloadSchema.safeParse(safeJsonParse(stdout));
|
|
205
|
+
const vulnerabilities = parsed.success ? parsed.data.metadata?.vulnerabilities : void 0;
|
|
206
|
+
if (vulnerabilities === void 0) return [{
|
|
207
|
+
ruleId: "template-kit/audit-severity",
|
|
208
|
+
template: "",
|
|
209
|
+
message: "npm audit did not return a parseable report — the audit could not run.",
|
|
210
|
+
fix: "Run npm audit --json in the workspace and fix whatever is preventing it (usually missing network access), then re-run check."
|
|
211
|
+
}];
|
|
212
|
+
const offendingSeverities = CHECK_CONFIG.audit.failOn.filter((severity) => (vulnerabilities[severity] ?? 0) > 0);
|
|
213
|
+
if (offendingSeverities.length === 0) return [];
|
|
214
|
+
return [{
|
|
215
|
+
ruleId: "template-kit/audit-severity",
|
|
216
|
+
template: "",
|
|
217
|
+
message: `npm audit found ${offendingSeverities.map((severity) => `${String(vulnerabilities[severity] ?? 0)} ${severity}`).join(", ")} severity advisories.`,
|
|
218
|
+
fix: AUDIT_SEVERITY_FIX
|
|
219
|
+
}];
|
|
220
|
+
}
|
|
221
|
+
const LICENSE_DENIED_FIX = "Only permissively licensed packages can ship in a template. Replace this one, or ask us to review it.";
|
|
222
|
+
const lockPackageEntrySchema = z.object({
|
|
223
|
+
version: z.string().optional(),
|
|
224
|
+
license: z.string().optional(),
|
|
225
|
+
dependencies: z.record(z.string(), z.string()).optional(),
|
|
226
|
+
peerDependencies: z.record(z.string(), z.string()).optional(),
|
|
227
|
+
peerDependenciesMeta: z.record(z.string(), z.object({ optional: z.boolean().optional() }).passthrough()).optional()
|
|
228
|
+
}).passthrough();
|
|
229
|
+
const lockFileSchema = z.object({ packages: z.record(z.string(), lockPackageEntrySchema) }).passthrough();
|
|
230
|
+
/** `"node_modules/@scope/name/node_modules/other"` -> `["@scope/name", "other"]`; `""` -> `[]`. */
|
|
231
|
+
function parseChain(key) {
|
|
232
|
+
if (key === "") return [];
|
|
233
|
+
return key.split("/node_modules/").map((segment, i) => i === 0 ? segment.replace(/^node_modules\//, "") : segment);
|
|
234
|
+
}
|
|
235
|
+
function buildKey(chain) {
|
|
236
|
+
return chain.length === 0 ? "" : `node_modules/${chain.join("/node_modules/")}`;
|
|
237
|
+
}
|
|
238
|
+
/** Node's own nearest-ancestor `node_modules` resolution, applied to lockfile keys instead of disk. */
|
|
239
|
+
function resolveDependencyKey(packages, fromKey, name) {
|
|
240
|
+
const chain = parseChain(fromKey);
|
|
241
|
+
for (let i = chain.length; i >= 0; i--) {
|
|
242
|
+
const candidate = buildKey([...chain.slice(0, i), name]);
|
|
243
|
+
if (Object.hasOwn(packages, candidate)) return candidate;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
/**
|
|
247
|
+
* The set of lockfile package keys in the production install tree (what an
|
|
248
|
+
* `npm ci --omit=dev` leaves on disk): the root's `dependencies` (never its
|
|
249
|
+
* `devDependencies`), walked transitively through each package's own
|
|
250
|
+
* `dependencies` and its `peerDependencies`. A `devDependencies` edge is a
|
|
251
|
+
* dead end. An optional peer is walked only when it resolves to an installed
|
|
252
|
+
* entry — one the workspace has anyway is kept by `npm ci --omit=dev`; one it
|
|
253
|
+
* lacks never installs and resolves to nothing.
|
|
254
|
+
*/
|
|
255
|
+
function shippedPackageKeys(packages) {
|
|
256
|
+
const root = packages[""];
|
|
257
|
+
const visited = /* @__PURE__ */ new Set();
|
|
258
|
+
const queue = [];
|
|
259
|
+
const seed = (fromKey, deps) => {
|
|
260
|
+
if (!deps) return;
|
|
261
|
+
for (const name of Object.keys(deps)) {
|
|
262
|
+
const key = resolveDependencyKey(packages, fromKey, name);
|
|
263
|
+
if (key !== void 0) queue.push(key);
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
seed("", root?.dependencies);
|
|
267
|
+
for (let key = queue.pop(); key !== void 0; key = queue.pop()) {
|
|
268
|
+
if (visited.has(key)) continue;
|
|
269
|
+
visited.add(key);
|
|
270
|
+
const entry = packages[key];
|
|
271
|
+
if (!entry) continue;
|
|
272
|
+
seed(key, entry.dependencies);
|
|
273
|
+
if (entry.peerDependencies) for (const name of Object.keys(entry.peerDependencies)) {
|
|
274
|
+
const peerKey = resolveDependencyKey(packages, key, name);
|
|
275
|
+
if (peerKey !== void 0) queue.push(peerKey);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return visited;
|
|
279
|
+
}
|
|
280
|
+
function packageNameFromKey(key) {
|
|
281
|
+
const chain = parseChain(key);
|
|
282
|
+
return chain[chain.length - 1] ?? key;
|
|
283
|
+
}
|
|
284
|
+
async function licenseOf(entry, workspaceRoot, key) {
|
|
285
|
+
if (entry.license !== void 0 && entry.license !== "") return entry.license;
|
|
286
|
+
const raw = await readJsonIfPresent(join(workspaceRoot, key, "package.json")).catch(() => void 0);
|
|
287
|
+
const parsed = packageManifestLicenseSchema.safeParse(raw);
|
|
288
|
+
return parsed.success ? parsed.data.license : void 0;
|
|
289
|
+
}
|
|
290
|
+
const packageManifestLicenseSchema = z.object({ license: z.string().optional() }).passthrough();
|
|
291
|
+
async function licenseDeniedGate(lockfileText, workspaceRoot) {
|
|
292
|
+
const parsed = lockFileSchema.safeParse(safeJsonParse(lockfileText));
|
|
293
|
+
if (!parsed.success) return [];
|
|
294
|
+
const packages = parsed.data.packages;
|
|
295
|
+
const shipped = shippedPackageKeys(packages);
|
|
296
|
+
const out = [];
|
|
297
|
+
for (const key of shipped) {
|
|
298
|
+
const name = packageNameFromKey(key);
|
|
299
|
+
if (CHECK_CONFIG.licenseExemptions.includes(name)) continue;
|
|
300
|
+
const entry = packages[key];
|
|
301
|
+
if (!entry) continue;
|
|
302
|
+
const license = await licenseOf(entry, workspaceRoot, key);
|
|
303
|
+
const version = entry.version ?? "unknown version";
|
|
304
|
+
if (license === void 0) {
|
|
305
|
+
out.push({
|
|
306
|
+
ruleId: "template-kit/license-denied",
|
|
307
|
+
template: "",
|
|
308
|
+
message: `${name}@${version} has no license field.`,
|
|
309
|
+
fix: LICENSE_DENIED_FIX
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (!CHECK_CONFIG.licenseAllowlist.includes(license)) out.push({
|
|
314
|
+
ruleId: "template-kit/license-denied",
|
|
315
|
+
template: "",
|
|
316
|
+
message: `${name}@${version} is licensed ${license}, which is not on the allowlist.`,
|
|
317
|
+
fix: LICENSE_DENIED_FIX
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
async function depsStage(workspaceRoot, run = defaultRun) {
|
|
323
|
+
const lockfilePath = join(workspaceRoot, "package-lock.json");
|
|
324
|
+
const hasLockfile = await isFile(lockfilePath);
|
|
325
|
+
const diagnostics = [...lockfileMissingGate(hasLockfile)];
|
|
326
|
+
if (hasLockfile) {
|
|
327
|
+
diagnostics.push(...await lockfileStaleGate(run, workspaceRoot));
|
|
328
|
+
const lockfileText = await readFile(lockfilePath, "utf8");
|
|
329
|
+
diagnostics.push(...await licenseDeniedGate(lockfileText, workspaceRoot));
|
|
330
|
+
}
|
|
331
|
+
diagnostics.push(...await singleReactGate(workspaceRoot));
|
|
332
|
+
diagnostics.push(...await auditSeverityGate(run, workspaceRoot));
|
|
333
|
+
return diagnostics;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
//#endregion
|
|
337
|
+
export { defaultRun, depsStage };
|