@helical-design/substrate 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.
Files changed (56) hide show
  1. package/README.md +23 -0
  2. package/bin/create-helical.mjs +59 -0
  3. package/package.json +40 -0
  4. package/src/build-all.mjs +59 -0
  5. package/src/build.mjs +31 -0
  6. package/src/content.mjs +6 -0
  7. package/src/db/client.mjs +45 -0
  8. package/src/db/schema.mjs +15 -0
  9. package/src/db/vector.mjs +10 -0
  10. package/src/harness.mjs +449 -0
  11. package/src/index.mjs +19 -0
  12. package/src/pages.mjs +14 -0
  13. package/src/palette.mjs +39 -0
  14. package/src/registry.mjs +29 -0
  15. package/src/render.mjs +43 -0
  16. package/src/scaffold.mjs +200 -0
  17. package/src/scale.mjs +268 -0
  18. package/src/store.mjs +38 -0
  19. package/src/tokens.mjs +79 -0
  20. package/templates/db/_shared/drizzle.config.mjs +9 -0
  21. package/templates/db/neon/client.mjs +7 -0
  22. package/templates/db/neon/env.example +6 -0
  23. package/templates/db/supabase/client.mjs +7 -0
  24. package/templates/db/supabase/config.toml +11 -0
  25. package/templates/db/supabase/env.example +8 -0
  26. package/templates/db/supabase/migrations/0001_init.sql +12 -0
  27. package/templates/profiles/_base/README.md +22 -0
  28. package/templates/profiles/_base/assets/base.css +50 -0
  29. package/templates/profiles/_base/content/about.md +4 -0
  30. package/templates/profiles/_base/content/site.json +1 -0
  31. package/templates/profiles/_base/pages.json +4 -0
  32. package/templates/profiles/_base/templates/layout.html +1 -0
  33. package/templates/profiles/_base/templates/pages/about.html +2 -0
  34. package/templates/profiles/_base/templates/pages/home.html +3 -0
  35. package/templates/profiles/_base/tokens/accent.dark.json +7 -0
  36. package/templates/profiles/_base/tokens/accent.light.json +7 -0
  37. package/templates/profiles/_base/tokens/base.json +18 -0
  38. package/templates/profiles/_base/tokens/breakpoints.json +38 -0
  39. package/templates/profiles/_base/tokens/palette.dark.json +96 -0
  40. package/templates/profiles/_base/tokens/palette.light.json +96 -0
  41. package/templates/profiles/_base/tokens/scale.compact.json +68 -0
  42. package/templates/profiles/_base/tokens/scale.json +114 -0
  43. package/templates/profiles/_base/tokens/scale.spacious.json +68 -0
  44. package/templates/profiles/_base/tokens/semantic.json +14 -0
  45. package/templates/profiles/app/.keep +0 -0
  46. package/templates/profiles/marketing/.keep +0 -0
  47. package/tokens/accent.dark.json +7 -0
  48. package/tokens/accent.light.json +7 -0
  49. package/tokens/base.json +18 -0
  50. package/tokens/breakpoints.json +38 -0
  51. package/tokens/palette.dark.json +96 -0
  52. package/tokens/palette.light.json +96 -0
  53. package/tokens/scale.compact.json +68 -0
  54. package/tokens/scale.json +114 -0
  55. package/tokens/scale.spacious.json +68 -0
  56. package/tokens/semantic.json +14 -0
@@ -0,0 +1,449 @@
1
+ // src/harness.mjs — reusable build-substrate checks. Each check takes a project
2
+ // `root` and returns string[] (empty = pass, non-empty = offender descriptions).
3
+ // runHarness aggregates them into { ok, violations }. Scaffolded projects inherit
4
+ // this harness so every surface is held to the same gates.
5
+ import {
6
+ readFileSync, readdirSync, statSync, existsSync, mkdtempSync, rmSync,
7
+ } from 'node:fs';
8
+ import { join, relative, extname } from 'node:path';
9
+ import { tmpdir } from 'node:os';
10
+ import { wcagContrast } from 'culori';
11
+ import { loadStore } from './store.mjs';
12
+ import { buildContent } from './build.mjs';
13
+ import { buildTokens } from './tokens.mjs';
14
+ import { PROFILES, buildScale, generateScale } from './scale.mjs';
15
+
16
+ const HEX = /#[0-9a-fA-F]{3,8}\b/;
17
+ const AA = 4.5;
18
+ // Static-asset extensions. An href ending in one of these is an asset reference
19
+ // (stylesheet, script, image, font…), not a navigational page link — link
20
+ // integrity only governs page-to-page navigation.
21
+ const ASSET_EXT = new Set([
22
+ '.css', '.js', '.mjs', '.svg', '.png', '.jpg', '.jpeg', '.gif', '.webp',
23
+ '.ico', '.woff', '.woff2', '.map', '.json', '.txt', '.xml', '.pdf',
24
+ ]);
25
+ // Context keys supplied by the build runtime rather than the content store:
26
+ // the layout body slot, and per-entry loop aliases / collection slugs.
27
+ const RUNTIME_KEYS = new Set(['body', 'slug', 'entry']);
28
+
29
+ // --- shared helpers ---------------------------------------------------------
30
+
31
+ function walk(dir, exts, out = []) {
32
+ if (!existsSync(dir)) return out;
33
+ for (const f of readdirSync(dir)) {
34
+ const p = join(dir, f);
35
+ if (statSync(p).isDirectory()) { walk(p, exts, out); continue; }
36
+ if (exts.includes(extname(f))) out.push(p);
37
+ }
38
+ return out;
39
+ }
40
+
41
+ function readPages(root) {
42
+ const manifest = join(root, 'pages.json');
43
+ return existsSync(manifest) ? JSON.parse(readFileSync(manifest, 'utf8')) : [];
44
+ }
45
+
46
+ function pageTemplates(root) {
47
+ return walk(join(root, 'templates', 'pages'), ['.html']);
48
+ }
49
+
50
+ // Strip {{ ... }} interpolation/blocks so we can reason about literal markup.
51
+ function stripInterpolation(html) {
52
+ return html.replace(/\{\{[\s\S]*?\}\}/g, '');
53
+ }
54
+
55
+ // Build the project into a fresh temp dir and return that dir. Throws on build
56
+ // failure; callers wrap in try/catch and surface the message as a violation.
57
+ function buildInto(root, dir) {
58
+ buildContent({
59
+ contentDir: join(root, 'content'),
60
+ templatesDir: join(root, 'templates'),
61
+ outDir: dir,
62
+ pages: readPages(root),
63
+ });
64
+ return dir;
65
+ }
66
+
67
+ // Build tokens into a throwaway temp dir and return the combined CSS. Never
68
+ // touches the working-tree dist/ — every build-dependent check is isolated so
69
+ // parallel test files can't race on a shared output path.
70
+ async function buildTokensCss(root) {
71
+ const dir = mkdtempSync(join(tmpdir(), 'helical-harness-'));
72
+ try {
73
+ await buildTokens(root, dir);
74
+ return readFileSync(join(dir, 'tokens.css'), 'utf8');
75
+ } finally {
76
+ rmSync(dir, { recursive: true, force: true });
77
+ }
78
+ }
79
+
80
+ // --- checks -----------------------------------------------------------------
81
+
82
+ // Hardcoded hex literals in templates/components (must use var()/tokens instead).
83
+ // `dirs` overrides which subdirectories of `root` are scanned (default: the
84
+ // template + component trees a scaffolded project ships).
85
+ export function checkNoHardcodedHex(root, { dirs = ['templates', 'components'] } = {}) {
86
+ const offenders = [];
87
+ for (const base of dirs) {
88
+ for (const p of walk(join(root, base), ['.html', '.css'])) {
89
+ readFileSync(p, 'utf8').split('\n').forEach((line, i) => {
90
+ if (HEX.test(line)) offenders.push(`${relative(root, p)}:${i + 1}: ${line.trim()}`);
91
+ });
92
+ }
93
+ }
94
+ return offenders;
95
+ }
96
+
97
+ // User-visible prose sitting outside {{ }} in a page template. Heading text
98
+ // (h1–h6) is the one sanctioned exception (section/heading labels live in markup).
99
+ export function checkNoInlineCopy(root) {
100
+ const offenders = [];
101
+ for (const p of pageTemplates(root)) {
102
+ let html = stripInterpolation(readFileSync(p, 'utf8'));
103
+ // Headings are exempt: drop their text content before inspecting the rest.
104
+ html = html.replace(/<(h[1-6])\b[^>]*>[\s\S]*?<\/\1>/gi, '');
105
+ // Inspect text nodes that sit between tags.
106
+ for (const m of html.matchAll(/>([^<]+)</g)) {
107
+ const text = m[1].replace(/\s+/g, ' ').trim();
108
+ // Prose = a real sentence of words; ignore whitespace, slot markers, and
109
+ // stray single tokens (e.g. %%BODY%%) that aren't user-facing copy.
110
+ const words = text.split(' ').filter((w) => /[A-Za-z]/.test(w));
111
+ if (words.length >= 3) offenders.push(`${relative(root, p)}: inline copy: "${text}"`);
112
+ }
113
+ }
114
+ return offenders;
115
+ }
116
+
117
+ // Collect the top-level context keys a page template references.
118
+ function referencedKeys(html) {
119
+ const keys = new Set();
120
+ for (const m of html.matchAll(/\{\{\s*#?\s*(?:each\s+)?([\w.]+)/g)) {
121
+ const head = m[1].split('.')[0];
122
+ if (head && head !== '.') keys.add(head);
123
+ }
124
+ return keys;
125
+ }
126
+
127
+ // Orphan content keys (in store, referenced by no template) AND template-
128
+ // referenced keys missing from the store.
129
+ export function checkContentCompleteness(root) {
130
+ const contentDir = join(root, 'content');
131
+ const store = existsSync(contentDir) ? loadStore(contentDir) : {};
132
+ const storeKeys = new Set(Object.keys(store));
133
+
134
+ // Loop aliases declared in the page manifest are runtime-provided, not store keys.
135
+ const aliases = new Set(RUNTIME_KEYS);
136
+ for (const p of readPages(root)) if (p.as) aliases.add(p.as);
137
+
138
+ const referenced = new Set();
139
+ for (const p of pageTemplates(root)) {
140
+ for (const k of referencedKeys(readFileSync(p, 'utf8'))) referenced.add(k);
141
+ }
142
+
143
+ const offenders = [];
144
+ for (const k of storeKeys) {
145
+ if (!referenced.has(k)) offenders.push(`orphan content key: ${k} (referenced by no template)`);
146
+ }
147
+ for (const k of referenced) {
148
+ if (!storeKeys.has(k) && !aliases.has(k)) offenders.push(`missing content key: ${k} (referenced by a template, absent from store)`);
149
+ }
150
+ return offenders;
151
+ }
152
+
153
+ // Internal href="/x" links with no corresponding output page.
154
+ //
155
+ // Static by design: the set of expected output pages is derived directly from
156
+ // the page manifest (the same `pages` list buildContent consumes), and internal
157
+ // links are scanned from the raw template files. This check never builds the
158
+ // project, so an unrelated missing content key (a content-completeness
159
+ // violation) can no longer crash it — a broken link and a missing key are
160
+ // independent violations and each is reported by its own check.
161
+ export function checkLinkIntegrity(root) {
162
+ // 1. Expected output pages from the manifest. Collection rows (forEach) expand
163
+ // one output per item; here we can't know the slugs without the store, so a
164
+ // parametric `:slug` out is treated as a wildcard target prefix below.
165
+ const outs = [];
166
+ const slugPrefixes = [];
167
+ for (const p of readPages(root)) {
168
+ if (!p.out) continue;
169
+ if (p.out.includes(':slug')) {
170
+ // e.g. "journal/:slug.html" -> any href under "journal/" is considered live.
171
+ slugPrefixes.push('/' + p.out.split(':slug')[0].split('\\').join('/'));
172
+ } else {
173
+ outs.push('/' + p.out.split('\\').join('/'));
174
+ }
175
+ }
176
+ const pages = new Set(outs);
177
+
178
+ const isKnown = (href) => {
179
+ const target = href === '/' ? '/index.html' : href;
180
+ const candidates = [target];
181
+ if (!extname(target)) candidates.push(target + '.html', target + '/index.html');
182
+ if (candidates.some((c) => pages.has(c))) return true;
183
+ // A parametric collection out (".../:slug.html") covers any href under it.
184
+ return slugPrefixes.some((pre) => target.startsWith(pre));
185
+ };
186
+
187
+ // 2. Scan raw template files (pages, layout, partials) for internal hrefs.
188
+ const templatesDir = join(root, 'templates');
189
+ const templateFiles = [
190
+ ...pageTemplates(root),
191
+ ...walk(templatesDir, ['.html']).filter((p) => !p.includes(join('templates', 'pages'))),
192
+ ];
193
+ const seen = new Set();
194
+ const offenders = [];
195
+ for (const p of templateFiles) {
196
+ if (seen.has(p)) continue;
197
+ seen.add(p);
198
+ const html = readFileSync(p, 'utf8');
199
+ for (const m of html.matchAll(/href="(\/[^"#?]*)/g)) {
200
+ const href = m[1];
201
+ if (ASSET_EXT.has(extname(href).toLowerCase())) continue; // asset ref, not a page link
202
+ if (!isKnown(href)) {
203
+ offenders.push(`broken internal link: href="${href}" has no output page`);
204
+ }
205
+ }
206
+ }
207
+ return offenders;
208
+ }
209
+
210
+ // Parse a tokens.css mode block into { varName: hex }.
211
+ function modeVars(css, selector) {
212
+ const m = css.match(new RegExp(selector.replace(/[[\]]/g, '\\$&') + '\\s*{([^}]*)}'));
213
+ const vars = {};
214
+ for (const line of (m ? m[1] : '').split(';')) {
215
+ const kv = line.match(/--([\w-]+):\s*(#[0-9a-fA-F]{6})/);
216
+ if (kv) vars[kv[1]] = kv[2];
217
+ }
218
+ return vars;
219
+ }
220
+
221
+ const CONTRAST_PAIRS = [
222
+ ['color-text', 'color-bg'], ['color-text', 'color-surface'],
223
+ ['color-text-secondary', 'color-bg'], ['color-on-accent', 'color-accent-solid'],
224
+ ];
225
+
226
+ // Token text/surface pairs that fall below WCAG AA in either mode.
227
+ export async function checkContrastAA(root) {
228
+ const offenders = [];
229
+ let css;
230
+ try {
231
+ css = await buildTokensCss(root);
232
+ } catch (e) {
233
+ return [`contrast-AA: token build failed: ${e.message}`];
234
+ }
235
+ for (const [mode, selector] of [['dark', ':root'], ['light', '[data-mode="light"]']]) {
236
+ const vars = modeVars(css, selector);
237
+ for (const [fg, bg] of CONTRAST_PAIRS) {
238
+ if (!vars[fg] || !vars[bg]) continue;
239
+ const ratio = wcagContrast(vars[fg], vars[bg]);
240
+ if (ratio < AA) offenders.push(`${mode}: ${fg} on ${bg} = ${ratio.toFixed(2)} (< ${AA})`);
241
+ }
242
+ }
243
+ return offenders;
244
+ }
245
+
246
+ // Two builds of the project are byte-identical (content + tokens).
247
+ export async function checkDeterminism(root) {
248
+ const a = mkdtempSync(join(tmpdir(), 'helical-det-a-'));
249
+ const b = mkdtempSync(join(tmpdir(), 'helical-det-b-'));
250
+ try {
251
+ buildInto(root, a);
252
+ buildInto(root, b);
253
+ const tokens = await buildTokensCss(root); // also exercise token determinism
254
+ const tokens2 = await buildTokensCss(root);
255
+ const offenders = [];
256
+ if (tokens !== tokens2) offenders.push('determinism: tokens.css differs between builds');
257
+ const filesA = walk(a, ['.html']).map((p) => relative(a, p)).sort();
258
+ const filesB = walk(b, ['.html']).map((p) => relative(b, p)).sort();
259
+ if (filesA.join('|') !== filesB.join('|')) {
260
+ offenders.push('determinism: output file set differs between builds');
261
+ }
262
+ for (const f of filesA) {
263
+ if (!filesB.includes(f)) continue;
264
+ if (readFileSync(join(a, f), 'utf8') !== readFileSync(join(b, f), 'utf8')) {
265
+ offenders.push(`determinism: ${f} differs between builds`);
266
+ }
267
+ }
268
+ return offenders;
269
+ } catch (e) {
270
+ return [`determinism: build failed: ${e.message}`];
271
+ } finally {
272
+ rmSync(a, { recursive: true, force: true });
273
+ rmSync(b, { recursive: true, force: true });
274
+ }
275
+ }
276
+
277
+ // Diff dist/ against a committed golden snapshot if one exists; [] when none.
278
+ export async function checkGoldenSnapshot(root, { goldenDir = join(root, 'golden') } = {}) {
279
+ if (!existsSync(goldenDir)) return [];
280
+ const dir = mkdtempSync(join(tmpdir(), 'helical-golden-'));
281
+ try {
282
+ buildInto(root, dir);
283
+ const offenders = [];
284
+ const goldenFiles = walk(goldenDir, ['.html']).map((p) => relative(goldenDir, p)).sort();
285
+ const builtFiles = walk(dir, ['.html']).map((p) => relative(dir, p)).sort();
286
+ for (const f of goldenFiles) {
287
+ if (!builtFiles.includes(f)) { offenders.push(`golden: missing output for ${f}`); continue; }
288
+ if (readFileSync(join(goldenDir, f), 'utf8') !== readFileSync(join(dir, f), 'utf8')) {
289
+ offenders.push(`golden: ${f} diverges from committed snapshot`);
290
+ }
291
+ }
292
+ for (const f of builtFiles) {
293
+ if (!goldenFiles.includes(f)) offenders.push(`golden: unexpected output ${f} (not in snapshot)`);
294
+ }
295
+ return offenders;
296
+ } catch (e) {
297
+ return [`golden: build failed: ${e.message}`];
298
+ } finally {
299
+ rmSync(dir, { recursive: true, force: true });
300
+ }
301
+ }
302
+
303
+ // --- scale guards -----------------------------------------------------------
304
+
305
+ const SIZE_ROLES = ['caption', 'small', 'body', 'h3', 'h2', 'h1'];
306
+ const isAscending = (a) => a.every((v, i) => i === 0 || v > a[i - 1]);
307
+
308
+ // Structural invariants of the generative scale, asserted on both shipped
309
+ // profiles: ladders strictly monotonic (both densities), the ratio guard rejects
310
+ // degenerate ratios, the a11y target clears 24px and is density-stable, and the
311
+ // compact font floor holds (body ≥14px, smallest role ≥12px).
312
+ export function checkScale() {
313
+ const offenders = [];
314
+ for (const cfg of Object.values(PROFILES)) {
315
+ let s;
316
+ try { s = buildScale(cfg); }
317
+ catch (e) { offenders.push(`scale: ${cfg.name} buildScale threw: ${e.message}`); continue; }
318
+
319
+ for (const d of ['comfy', 'compact', 'spacious']) {
320
+ if (!isAscending(s[d].space)) offenders.push(`scale: ${cfg.name} ${d} space not monotonic`);
321
+ const sizes = SIZE_ROLES.map((k) => s[d].size[k]);
322
+ if (!isAscending(sizes)) offenders.push(`scale: ${cfg.name} ${d} size not monotonic`);
323
+ for (const v of sizes) {
324
+ if (v < 12) offenders.push(`scale: ${cfg.name} ${d} type ${v}px < 12px a11y floor`);
325
+ }
326
+ }
327
+ if (s.compact.size.body < 14) offenders.push(`scale: ${cfg.name} compact body ${s.compact.size.body}px < 14px floor`);
328
+
329
+ // Cross-density monotonicity: compact ≤ comfy ≤ spacious at every step (ties OK
330
+ // at the grid-floored bottom). A density that crossed over would be a bug.
331
+ for (let i = 0; i < s.comfy.space.length; i++) {
332
+ if (!(s.compact.space[i] <= s.comfy.space[i] && s.comfy.space[i] <= s.spacious.space[i])) {
333
+ offenders.push(`scale: ${cfg.name} space step ${i + 1} breaks compact≤comfy≤spacious`);
334
+ }
335
+ }
336
+ for (const k of SIZE_ROLES) {
337
+ if (!(s.compact.size[k] <= s.comfy.size[k] && s.comfy.size[k] <= s.spacious.size[k])) {
338
+ offenders.push(`scale: ${cfg.name} size.${k} breaks compact≤comfy≤spacious`);
339
+ }
340
+ }
341
+
342
+ // Leading couples to density (v2): body tightens compact / loosens spacious,
343
+ // floored at 1.4; heading `tight` leading is density-stable (:root only).
344
+ if (!(s.compact.leading.body <= s.comfy.leading.body && s.comfy.leading.body <= s.spacious.leading.body)) {
345
+ offenders.push(`scale: ${cfg.name} leading.body not coupled compact≤comfy≤spacious`);
346
+ }
347
+ if (s.compact.leading.body < 1.4) offenders.push(`scale: ${cfg.name} compact leading.body ${s.compact.leading.body} < 1.4 floor`);
348
+
349
+ // a11y SC 2.5.8 — target.min ≥24px and density-stable. radius/tracking/target
350
+ // must never leak into a density block; the density set is ⊆ {space,size,leading}.
351
+ if (s.comfy.target.min < 24) offenders.push(`scale: ${cfg.name} target.min ${s.comfy.target.min}px < 24px (SC 2.5.8)`);
352
+ for (const d of ['compact', 'spacious']) {
353
+ if (s[d].target !== undefined) offenders.push(`scale: ${cfg.name} ${d} carries target (must be density-stable)`);
354
+ if (s[d].radius !== undefined) offenders.push(`scale: ${cfg.name} ${d} carries radius (must be density-stable)`);
355
+ const extra = Object.keys(s[d]).filter((k) => !['space', 'size', 'leading'].includes(k));
356
+ if (extra.length) offenders.push(`scale: ${cfg.name} ${d} carries groups beyond space+size+leading: ${extra.join(',')}`);
357
+ }
358
+
359
+ // Size-coupled tracking (em): tighter as type grows → h1 ≤ h2 ≤ h3 numerically
360
+ // (h1 most negative). Each within the profile's [min, max] bounds.
361
+ const { h1, h2, h3 } = s.comfy.tracking;
362
+ if (!(h1 <= h2 && h2 <= h3)) offenders.push(`scale: ${cfg.name} tracking not monotonic by size (h1≤h2≤h3): ${h1},${h2},${h3}`);
363
+ for (const [role, v] of Object.entries(s.comfy.tracking)) {
364
+ if (v < cfg.tracking.min || v > cfg.tracking.max) {
365
+ offenders.push(`scale: ${cfg.name} tracking.${role} ${v} outside [${cfg.tracking.min}, ${cfg.tracking.max}]`);
366
+ }
367
+ }
368
+ }
369
+ // Ratio guard wired correctly (degenerate ratios throw).
370
+ for (const bad of [1, 2, 0.9, 2.5]) {
371
+ let threw = false;
372
+ try { buildScale({ ...PROFILES.ui, typeRatio: bad }); } catch { threw = true; }
373
+ if (!threw) offenders.push(`scale: ratio guard failed to reject typeRatio=${bad}`);
374
+ }
375
+ return offenders;
376
+ }
377
+
378
+ // Anti-staleness: regenerate the committed scale goldens into a temp dir and
379
+ // byte-compare. Catches a hand-edited or out-of-date tokens/scale*.json — the
380
+ // self-comparing determinism check cannot. Skipped when no goldens exist.
381
+ export function checkScaleStaleness(root) {
382
+ const files = ['tokens/scale.json', 'tokens/scale.compact.json', 'tokens/scale.spacious.json'];
383
+ if (!files.every((f) => existsSync(join(root, f)))) return [];
384
+ const dir = mkdtempSync(join(tmpdir(), 'helical-scale-stale-'));
385
+ try {
386
+ generateScale('ui', dir);
387
+ const offenders = [];
388
+ for (const f of files) {
389
+ if (readFileSync(join(dir, f), 'utf8') !== readFileSync(join(root, f), 'utf8')) {
390
+ offenders.push(`scale: ${f} is stale vs generator output (run gen:scale)`);
391
+ }
392
+ }
393
+ return offenders;
394
+ } catch (e) {
395
+ return [`scale: staleness check failed: ${e.message}`];
396
+ } finally {
397
+ rmSync(dir, { recursive: true, force: true });
398
+ }
399
+ }
400
+
401
+ // Resolution: every var(--x) consumed by the seed base.css / showcase.css must
402
+ // have a matching --x: declaration in the built tokens.css. A scale rename or a
403
+ // dropped group surfaces here as a dangling var before it ships.
404
+ export async function checkScaleResolution(root, { consumers } = {}) {
405
+ const files = consumers ?? [
406
+ join(root, 'templates/profiles/_base/assets/base.css'),
407
+ join(root, 'showcase/showcase.css'),
408
+ ].filter((p) => existsSync(p));
409
+ if (!files.length) return [];
410
+ let css;
411
+ try { css = await buildTokensCss(root); }
412
+ catch (e) { return [`scale-resolution: token build failed: ${e.message}`]; }
413
+ const declared = new Set([...css.matchAll(/--([a-z0-9-]+):/g)].map((m) => m[1]));
414
+ const missing = new Set();
415
+ for (const f of files) {
416
+ for (const m of readFileSync(f, 'utf8').matchAll(/var\(--([a-z0-9-]+)\)/g)) {
417
+ if (!declared.has(m[1])) missing.add(m[1]);
418
+ }
419
+ }
420
+ return [...missing].map((v) => `scale-resolution: var(--${v}) consumed but never declared`);
421
+ }
422
+
423
+ // Pure-static checks (synchronous) and build-dependent checks (async). They are
424
+ // kept separate so the runner can await the latter without forcing the former.
425
+ const SYNC_CHECKS = {
426
+ checkNoHardcodedHex, checkNoInlineCopy, checkContentCompleteness, checkLinkIntegrity,
427
+ checkScale, checkScaleStaleness,
428
+ };
429
+ const ASYNC_CHECKS = {
430
+ checkContrastAA, checkDeterminism, checkGoldenSnapshot, checkScaleResolution,
431
+ };
432
+
433
+ // Aggregate every check into a single { ok, violations } result.
434
+ export async function runHarness(root, opts = {}) {
435
+ const violations = [];
436
+ for (const [name, fn] of Object.entries(SYNC_CHECKS)) {
437
+ let out;
438
+ try { out = fn(root, opts); }
439
+ catch (e) { out = [`${name}: threw: ${e.message}`]; }
440
+ for (const o of out) violations.push(`[${name}] ${o}`);
441
+ }
442
+ for (const [name, fn] of Object.entries(ASYNC_CHECKS)) {
443
+ let out;
444
+ try { out = await fn(root, opts); }
445
+ catch (e) { out = [`${name}: threw: ${e.message}`]; }
446
+ for (const o of out) violations.push(`[${name}] ${o}`);
447
+ }
448
+ return { ok: violations.length === 0, violations };
449
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,19 @@
1
+ export { render } from './render.mjs';
2
+ export { loadStore } from './store.mjs';
3
+ export { resolvePages } from './pages.mjs';
4
+ export { buildContent } from './build.mjs';
5
+ export { build } from './build-all.mjs';
6
+ export { generatePalette } from './palette.mjs';
7
+ export { PROFILES, buildScale, generateScale } from './scale.mjs';
8
+ export { buildTokens } from './tokens.mjs';
9
+ export { buildRegistry } from './registry.mjs';
10
+ export {
11
+ checkNoHardcodedHex, checkNoInlineCopy, checkContentCompleteness,
12
+ checkLinkIntegrity, checkContrastAA, checkDeterminism, checkGoldenSnapshot,
13
+ checkScale, checkScaleStaleness, checkScaleResolution,
14
+ runHarness,
15
+ } from './harness.mjs';
16
+ export { scaffold } from './scaffold.mjs';
17
+ export { createDb, assertPooled } from './db/client.mjs';
18
+ export { nearest } from './db/vector.mjs';
19
+ export { documents } from './db/schema.mjs';
package/src/pages.mjs ADDED
@@ -0,0 +1,14 @@
1
+ // Resolve a page manifest into concrete pages. Config-driven; supports collection expansion.
2
+ export function resolvePages(pages, store) {
3
+ const out = [];
4
+ for (const p of pages) {
5
+ if (p.forEach) {
6
+ for (const item of (store[p.forEach] || [])) {
7
+ out.push({ out: p.out.replace(':slug', item.slug), template: p.template, context: { ...store, [p.as || 'entry']: item } });
8
+ }
9
+ } else {
10
+ out.push({ out: p.out, template: p.template, context: store });
11
+ }
12
+ }
13
+ return out;
14
+ }
@@ -0,0 +1,39 @@
1
+ // Editorial Neon — Leonardo-generated accessible ramps, emitted as DTCG per mode.
2
+ // Neutral = violet-tinted gray (synth heart); single brand accent = cyan.
3
+ import { Theme, Color, BackgroundColor } from '@adobe/leonardo-contrast-colors';
4
+ import { writeFileSync, mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+
7
+ const neutral = new BackgroundColor({ name: 'neutral', colorKeys: ['#8d86a8'], ratios: [1.12, 1.35, 1.9, 3, 4.5, 7, 11, 16] });
8
+ const accent = new Color({ name: 'accent', colorKeys: ['#36d6e6'], ratios: [1.4, 2.4, 3.2, 4.5, 7, 9.5] });
9
+ const success = new Color({ name: 'success', colorKeys: ['#3fd6a0'], ratios: [3, 4.5, 7] });
10
+ const danger = new Color({ name: 'danger', colorKeys: ['#ff5d73'], ratios: [3, 4.5, 7] });
11
+
12
+ function ramps(lightness) {
13
+ const t = new Theme({ colors: [neutral, accent, success, danger], backgroundColor: neutral, lightness, contrast: 1, saturation: 100 });
14
+ const out = {};
15
+ for (const c of t.contrastColors) {
16
+ if (c.background) { out.bg = c.background; continue; }
17
+ out[c.name] = c.values.map((v) => v.value);
18
+ }
19
+ return out;
20
+ }
21
+
22
+ function toDtcg(r) {
23
+ const grp = (arr) => Object.fromEntries(arr.map((hex, i) => [String(i + 1), { $value: hex, $type: 'color' }]));
24
+ return {
25
+ palette: {
26
+ bg: { $value: r.bg, $type: 'color' },
27
+ neutral: grp(r.neutral),
28
+ accent: grp(r.accent),
29
+ success: grp(r.success),
30
+ danger: grp(r.danger)
31
+ }
32
+ };
33
+ }
34
+
35
+ export function generatePalette(root = '.') {
36
+ mkdirSync(join(root, 'tokens'), { recursive: true });
37
+ writeFileSync(join(root, 'tokens/palette.dark.json'), JSON.stringify(toDtcg(ramps(8)), null, 2) + '\n');
38
+ writeFileSync(join(root, 'tokens/palette.light.json'), JSON.stringify(toDtcg(ramps(97)), null, 2) + '\n');
39
+ }
@@ -0,0 +1,29 @@
1
+ // Convert built CSS variables into a shadcn registry:theme item.
2
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+
5
+ function vars(css, selector) {
6
+ const m = css.match(new RegExp(selector.replace(/[[\]]/g, '\\$&') + '\\s*{([^}]*)}'));
7
+ const out = {};
8
+ for (const line of (m ? m[1] : '').split(';')) {
9
+ const kv = line.match(/--([\w-]+):\s*([^;]+)/);
10
+ if (kv) out[kv[1].trim()] = kv[2].trim();
11
+ }
12
+ return out;
13
+ }
14
+
15
+ export function buildRegistry(root = '.') {
16
+ const css = readFileSync(join(root, 'dist/tokens.css'), 'utf8');
17
+ const item = {
18
+ $schema: 'https://ui.shadcn.com/schema/registry-item.json',
19
+ name: 'editorial-neon',
20
+ type: 'registry:theme',
21
+ cssVars: {
22
+ dark: vars(css, ':root'),
23
+ light: vars(css, '[data-mode="light"]')
24
+ }
25
+ };
26
+
27
+ mkdirSync(join(root, 'dist/registry'), { recursive: true });
28
+ writeFileSync(join(root, 'dist/registry/editorial-neon.json'), JSON.stringify(item, null, 2) + '\n');
29
+ }
package/src/render.mjs ADDED
@@ -0,0 +1,43 @@
1
+ // Deterministic, fail-loud template resolver.
2
+ // Grammar: {{ a.b.c }} | {{ . }} | {{> partial }} | {{# each key }}…{{/each}}
3
+
4
+ function lookup(path, ctx) {
5
+ if (path === '.') return ctx;
6
+ const val = path.split('.').reduce((o, k) => (o == null ? o : o[k]), ctx);
7
+ if (val === undefined || val === null) throw new Error(`render: missing key: ${path}`);
8
+ return val;
9
+ }
10
+
11
+ function renderEach(tpl, ctx) {
12
+ // single-level only — nested {{# each }} is not supported (non-greedy regex).
13
+ const re = /\{\{#\s*each\s+([\w.]+)\s*\}\}([\s\S]*?)\{\{\/each\}\}/g;
14
+ return tpl.replace(re, (_, key, body) => {
15
+ const arr = lookup(key, ctx);
16
+ if (!Array.isArray(arr)) throw new Error(`render: 'each ${key}' is not an array`);
17
+ return arr.map((item) => renderVars(body, item)).join('');
18
+ });
19
+ }
20
+
21
+ function renderVars(tpl, ctx) {
22
+ return tpl.replace(/\{\{\s*([\w.]+|\.)\s*\}\}/g, (_, path) => {
23
+ const val = lookup(path, ctx);
24
+ // fail-loud: an object/array means an authoring mistake (use {{# each }}), not [object Object].
25
+ if (typeof val === 'object') throw new Error(`render: key '${path}' is an object/array; use {{# each }} instead`);
26
+ return String(val);
27
+ });
28
+ }
29
+
30
+ export function render(tpl, ctx, partials = {}, depth = 0) {
31
+ // guards against accidental partial cycles; 20 is far beyond any real template depth.
32
+ if (depth > 20) throw new Error('render: partial recursion too deep');
33
+ // 1. partials first (so they can contain each/vars)
34
+ let out = tpl.replace(/\{\{>\s*([\w-]+)\s*\}\}/g, (_, name) => {
35
+ if (!(name in partials)) throw new Error(`render: unknown partial: ${name}`);
36
+ return render(partials[name], ctx, partials, depth + 1);
37
+ });
38
+ // 2. each blocks, 3. vars
39
+ out = renderVars(renderEach(out, ctx), ctx);
40
+ // 4. nothing may remain
41
+ if (out.includes('{{')) throw new Error(`render: unresolved/unbalanced template near: ${out.slice(out.indexOf('{{'), out.indexOf('{{') + 40)}`);
42
+ return out;
43
+ }