@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.
- package/README.md +23 -0
- package/bin/create-helical.mjs +59 -0
- package/package.json +40 -0
- package/src/build-all.mjs +59 -0
- package/src/build.mjs +31 -0
- package/src/content.mjs +6 -0
- package/src/db/client.mjs +45 -0
- package/src/db/schema.mjs +15 -0
- package/src/db/vector.mjs +10 -0
- package/src/harness.mjs +449 -0
- package/src/index.mjs +19 -0
- package/src/pages.mjs +14 -0
- package/src/palette.mjs +39 -0
- package/src/registry.mjs +29 -0
- package/src/render.mjs +43 -0
- package/src/scaffold.mjs +200 -0
- package/src/scale.mjs +268 -0
- package/src/store.mjs +38 -0
- package/src/tokens.mjs +79 -0
- package/templates/db/_shared/drizzle.config.mjs +9 -0
- package/templates/db/neon/client.mjs +7 -0
- package/templates/db/neon/env.example +6 -0
- package/templates/db/supabase/client.mjs +7 -0
- package/templates/db/supabase/config.toml +11 -0
- package/templates/db/supabase/env.example +8 -0
- package/templates/db/supabase/migrations/0001_init.sql +12 -0
- package/templates/profiles/_base/README.md +22 -0
- package/templates/profiles/_base/assets/base.css +50 -0
- package/templates/profiles/_base/content/about.md +4 -0
- package/templates/profiles/_base/content/site.json +1 -0
- package/templates/profiles/_base/pages.json +4 -0
- package/templates/profiles/_base/templates/layout.html +1 -0
- package/templates/profiles/_base/templates/pages/about.html +2 -0
- package/templates/profiles/_base/templates/pages/home.html +3 -0
- package/templates/profiles/_base/tokens/accent.dark.json +7 -0
- package/templates/profiles/_base/tokens/accent.light.json +7 -0
- package/templates/profiles/_base/tokens/base.json +18 -0
- package/templates/profiles/_base/tokens/breakpoints.json +38 -0
- package/templates/profiles/_base/tokens/palette.dark.json +96 -0
- package/templates/profiles/_base/tokens/palette.light.json +96 -0
- package/templates/profiles/_base/tokens/scale.compact.json +68 -0
- package/templates/profiles/_base/tokens/scale.json +114 -0
- package/templates/profiles/_base/tokens/scale.spacious.json +68 -0
- package/templates/profiles/_base/tokens/semantic.json +14 -0
- package/templates/profiles/app/.keep +0 -0
- package/templates/profiles/marketing/.keep +0 -0
- package/tokens/accent.dark.json +7 -0
- package/tokens/accent.light.json +7 -0
- package/tokens/base.json +18 -0
- package/tokens/breakpoints.json +38 -0
- package/tokens/palette.dark.json +96 -0
- package/tokens/palette.light.json +96 -0
- package/tokens/scale.compact.json +68 -0
- package/tokens/scale.json +114 -0
- package/tokens/scale.spacious.json +68 -0
- package/tokens/semantic.json +14 -0
package/src/harness.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/palette.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/registry.mjs
ADDED
|
@@ -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
|
+
}
|