@takazudo/zudo-doc 2.0.0 → 2.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.
@@ -0,0 +1,348 @@
1
+ #!/usr/bin/env node
2
+ // @takazudo/zudo-doc/bin/gen-component-tokens.mjs
3
+ //
4
+ // Package bin: regenerate (or check) the BEGIN/END-marked --zdc-* blocks from
5
+ // the single source of truth in packages/zudo-doc/src/config/component-tokens.ts.
6
+ //
7
+ // Each token declares a `surface`, and the generator ROUTES its backing rule to
8
+ // the matching stylesheet:
9
+ // - surface "content" → block in packages/zudo-doc/src/content.css
10
+ // - surface "chrome" → block in packages/zudo-doc/src/features.css
11
+ // A token missing `surface` is a hard error (the parser throws).
12
+ //
13
+ // Paths are resolved relative to this file's location (the package root),
14
+ // NOT process.cwd() — this tool operates on the package's own source files
15
+ // regardless of the working directory from which it is invoked.
16
+ //
17
+ // Usage (after pnpm install, via scripts in the root package.json):
18
+ // gen-component-tokens # rewrite the --zdc-* blocks in both files
19
+ // gen-component-tokens --check # verify committed blocks are up to date (exit 1 on drift)
20
+ //
21
+ // CI invokes the file by path from the repo root:
22
+ // node packages/zudo-doc/bin/gen-component-tokens.mjs --check
23
+ //
24
+ // Each block is a set of --zdc-* CSS custom-property rules grouped by selector,
25
+ // giving consumers a single `:root` override point to rebrand component-level
26
+ // typography/chrome without touching JSX or fighting specificity.
27
+ //
28
+ // Pure Node (fs only — NO npm deps). Idempotent: running twice produces no diff.
29
+ //
30
+ // MAINTENANCE: edit packages/zudo-doc/src/config/component-tokens.ts (the source
31
+ // of truth), then run `pnpm gen:component-tokens` and commit the regenerated
32
+ // content.css / features.css. Never hand-edit the block between the BEGIN/END
33
+ // markers.
34
+
35
+ import { readFileSync, writeFileSync, realpathSync } from "node:fs";
36
+ import { resolve, dirname } from "node:path";
37
+ import { fileURLToPath } from "node:url";
38
+
39
+ // Paths are relative to this bin file's location so the generator works
40
+ // regardless of cwd (running from the repo root, package dir, or CI).
41
+ const __dirname = dirname(fileURLToPath(import.meta.url));
42
+ const PKG_ROOT = resolve(__dirname, "..");
43
+
44
+ const TOKENS_PATH = resolve(PKG_ROOT, "src/config/component-tokens.ts");
45
+ const CONTENT_CSS_PATH = resolve(PKG_ROOT, "src/content.css");
46
+ const FEATURES_CSS_PATH = resolve(PKG_ROOT, "src/features.css");
47
+
48
+ // Default marker pair = the content-surface block. Kept under the original
49
+ // names so buildBlock/replaceBlock default to the content block, preserving the
50
+ // byte-identical content.css output (and the existing unit-test call sites).
51
+ const BEGIN_MARKER =
52
+ "/* BEGIN --zdc-* component tokens (generated by gen-component-tokens; do not edit by hand) */";
53
+ const END_MARKER = "/* END --zdc-* component tokens */";
54
+
55
+ // Chrome-surface block markers — a DISTINCT pair so the two blocks never
56
+ // collide when both live in files that the generator scans.
57
+ const CHROME_BEGIN_MARKER =
58
+ "/* BEGIN --zdc-* chrome component tokens (generated by gen-component-tokens; do not edit by hand) */";
59
+ const CHROME_END_MARKER = "/* END --zdc-* chrome component tokens */";
60
+
61
+ /**
62
+ * Surface routing table — source order is the file-write order. Each entry maps
63
+ * a token `surface` to its destination stylesheet, marker pair, and a
64
+ * repo-relative path used in console/drift messages.
65
+ */
66
+ const SURFACES = [
67
+ {
68
+ surface: "content",
69
+ cssPath: CONTENT_CSS_PATH,
70
+ relPath: "packages/zudo-doc/src/content.css",
71
+ beginMarker: BEGIN_MARKER,
72
+ endMarker: END_MARKER,
73
+ },
74
+ {
75
+ surface: "chrome",
76
+ cssPath: FEATURES_CSS_PATH,
77
+ relPath: "packages/zudo-doc/src/features.css",
78
+ beginMarker: CHROME_BEGIN_MARKER,
79
+ endMarker: CHROME_END_MARKER,
80
+ },
81
+ ];
82
+
83
+ /** The set of valid `surface` values, derived from the routing table. */
84
+ const VALID_SURFACES = new Set(SURFACES.map((s) => s.surface));
85
+
86
+ /**
87
+ * Parse the COMPONENT_TOKENS array out of component-tokens.ts WITHOUT importing
88
+ * it (this bin is a dependency-free .mjs and cannot resolve TypeScript). Reads
89
+ * each `{ cssVar: "...", selector: "...", property: "...", default: "...",
90
+ * surface: "...", ... }` object literal. Throws on a malformed source — or a
91
+ * token missing the required `surface` field — so drift between the parser and
92
+ * the file surfaces loudly.
93
+ *
94
+ * Returns an array of { cssVar, selector, property, default, surface } objects
95
+ * in source order — iteration order is the CSS rule order.
96
+ *
97
+ * Exported for unit testing.
98
+ */
99
+ export function parseTokens(src) {
100
+ const arrayMatch = src.match(
101
+ /export const COMPONENT_TOKENS[^=]*=\s*\[([\s\S]*?)\];/,
102
+ );
103
+ if (!arrayMatch) {
104
+ throw new Error(
105
+ `Could not locate "export const COMPONENT_TOKENS = [ ... ]" in ${TOKENS_PATH}`,
106
+ );
107
+ }
108
+ const body = arrayMatch[1];
109
+ const tokens = [];
110
+
111
+ // Each token is a `{ ... }` object literal; iterate top-level braces.
112
+ // Description strings may contain backticks and parentheses but not braces,
113
+ // so lazy `{...}` matching correctly isolates each object.
114
+ const objectRe = /\{([\s\S]*?)\}/g;
115
+ let m;
116
+ while ((m = objectRe.exec(body)) !== null) {
117
+ const obj = m[1];
118
+ const cssVarMatch = obj.match(/cssVar:\s*"([^"]+)"/);
119
+ const selectorMatch = obj.match(/selector:\s*"([^"]+)"/);
120
+ const propertyMatch = obj.match(/property:\s*"([^"]+)"/);
121
+ const defaultMatch = obj.match(/default:\s*"([^"]+)"/);
122
+
123
+ if (!cssVarMatch || !selectorMatch || !propertyMatch || !defaultMatch) {
124
+ throw new Error(
125
+ `Malformed token object in COMPONENT_TOKENS (missing cssVar/selector/property/default): ${obj.trim()}`,
126
+ );
127
+ }
128
+
129
+ // `surface` is REQUIRED — it routes the rule to content.css vs features.css.
130
+ // Fail loudly rather than silently dropping a token from both files.
131
+ const surfaceMatch = obj.match(/surface:\s*"([^"]+)"/);
132
+ if (!surfaceMatch) {
133
+ throw new Error(
134
+ `Token ${cssVarMatch[1]} in COMPONENT_TOKENS is missing the required "surface" field ` +
135
+ `(expected surface: "content" | "chrome").`,
136
+ );
137
+ }
138
+
139
+ tokens.push({
140
+ cssVar: cssVarMatch[1],
141
+ selector: selectorMatch[1],
142
+ property: propertyMatch[1],
143
+ default: defaultMatch[1],
144
+ surface: surfaceMatch[1],
145
+ });
146
+ }
147
+
148
+ if (tokens.length === 0) {
149
+ throw new Error(
150
+ `COMPONENT_TOKENS in ${TOKENS_PATH} parsed to an empty list`,
151
+ );
152
+ }
153
+ return tokens;
154
+ }
155
+
156
+ /**
157
+ * Group tokens by selector, preserving the first-appearance order of selectors
158
+ * and the within-selector declaration order. Deterministic (source-order stable).
159
+ *
160
+ * Exported for unit testing.
161
+ */
162
+ export function groupBySelector(tokens) {
163
+ const map = new Map();
164
+ for (const token of tokens) {
165
+ if (!map.has(token.selector)) {
166
+ map.set(token.selector, []);
167
+ }
168
+ map.get(token.selector).push(token);
169
+ }
170
+ return map;
171
+ }
172
+
173
+ /**
174
+ * Partition tokens by their `surface`, returning a Map keyed by every known
175
+ * surface (in routing-table order) so callers can emit an EMPTY block for a
176
+ * surface that currently has no tokens (e.g. the seeded chrome block). Throws
177
+ * loudly on an unknown surface value — a routing typo must never silently drop
178
+ * a rule from both files.
179
+ *
180
+ * Exported for unit testing.
181
+ */
182
+ export function routeBySurface(tokens) {
183
+ const routes = new Map(SURFACES.map((s) => [s.surface, []]));
184
+ for (const token of tokens) {
185
+ if (!routes.has(token.surface)) {
186
+ throw new Error(
187
+ `Token ${token.cssVar} in COMPONENT_TOKENS has unknown surface "${token.surface}" ` +
188
+ `(expected one of: ${[...VALID_SURFACES].map((s) => `"${s}"`).join(", ")}).`,
189
+ );
190
+ }
191
+ routes.get(token.surface).push(token);
192
+ }
193
+ return routes;
194
+ }
195
+
196
+ /**
197
+ * Build the full generated block (markers included). Selectors are emitted in
198
+ * source-file order; declarations within each selector are in source-file order.
199
+ * No trailing newline — the caller appends css.slice(lineEnd) which starts with
200
+ * the \n that follows the END marker line.
201
+ *
202
+ * `beginMarker`/`endMarker` default to the CONTENT block markers so existing
203
+ * call sites keep producing the byte-identical content.css block; the chrome
204
+ * surface passes its own marker pair. An empty `tokens` array yields just the
205
+ * marker pair separated by a blank line (the seeded-empty chrome block).
206
+ *
207
+ * Exported for unit testing.
208
+ */
209
+ export function buildBlock(tokens, beginMarker = BEGIN_MARKER, endMarker = END_MARKER) {
210
+ const groups = groupBySelector(tokens);
211
+ const lines = [];
212
+
213
+ lines.push(beginMarker);
214
+
215
+ for (const [selector, entries] of groups) {
216
+ lines.push("");
217
+ lines.push(`${selector} {`);
218
+ for (const entry of entries) {
219
+ lines.push(` ${entry.property}: var(${entry.cssVar}, ${entry.default});`);
220
+ }
221
+ lines.push("}");
222
+ }
223
+
224
+ lines.push("");
225
+ lines.push(endMarker);
226
+
227
+ return lines.join("\n");
228
+ }
229
+
230
+ /**
231
+ * Replace the existing BEGIN…END block in `css` with `block`. Throws if the
232
+ * markers are missing (the block must be seeded once by hand — see content.css
233
+ * / features.css). `beginMarker`/`endMarker` default to the content block; the
234
+ * chrome surface passes its own pair. `cssPath` only feeds the error message.
235
+ *
236
+ * Exported for unit testing.
237
+ */
238
+ export function replaceBlock(
239
+ css,
240
+ block,
241
+ beginMarker = BEGIN_MARKER,
242
+ endMarker = END_MARKER,
243
+ cssPath = CONTENT_CSS_PATH,
244
+ ) {
245
+ const beginIdx = css.indexOf(beginMarker);
246
+ const endIdx = css.indexOf(endMarker);
247
+ if (beginIdx === -1 || endIdx === -1) {
248
+ throw new Error(
249
+ `Could not find BEGIN/END markers in ${cssPath}.\n` +
250
+ `Seed the marker block once by hand, then re-run the generator.`,
251
+ );
252
+ }
253
+ // Expand to the full line that opens the block and the end of the closing
254
+ // line so the whole region (including any per-selector comments the hand-
255
+ // written block may have had) is replaced cleanly.
256
+ const lineStart = css.lastIndexOf("\n", beginIdx) + 1;
257
+ const afterEnd = css.indexOf("\n", endIdx);
258
+ const lineEnd = afterEnd === -1 ? css.length : afterEnd;
259
+ return css.slice(0, lineStart) + block + css.slice(lineEnd);
260
+ }
261
+
262
+ function main() {
263
+ const check = process.argv.includes("--check");
264
+
265
+ const tokensSrc = readFileSync(TOKENS_PATH, "utf8");
266
+ const tokens = parseTokens(tokensSrc);
267
+ const routes = routeBySurface(tokens);
268
+
269
+ let drift = false;
270
+ let wroteAny = false;
271
+ const summaries = [];
272
+
273
+ for (const target of SURFACES) {
274
+ const surfaceTokens = routes.get(target.surface);
275
+ const selectorCount = [...groupBySelector(surfaceTokens).keys()].length;
276
+ summaries.push(
277
+ `${target.surface}: ${surfaceTokens.length} token(s), ${selectorCount} selector(s)`,
278
+ );
279
+
280
+ const css = readFileSync(target.cssPath, "utf8");
281
+ const block = buildBlock(surfaceTokens, target.beginMarker, target.endMarker);
282
+ const next = replaceBlock(
283
+ css,
284
+ block,
285
+ target.beginMarker,
286
+ target.endMarker,
287
+ target.cssPath,
288
+ );
289
+
290
+ if (next === css) continue;
291
+
292
+ if (check) {
293
+ console.error(
294
+ `component-tokens codegen drift detected: ${target.relPath} (${target.surface} surface) is out of date.`,
295
+ );
296
+ drift = true;
297
+ } else {
298
+ writeFileSync(target.cssPath, next);
299
+ wroteAny = true;
300
+ console.log(
301
+ `Wrote --zdc-* ${target.surface} component token block to ${target.relPath} ` +
302
+ `(${surfaceTokens.length} token(s), ${selectorCount} selector(s)).`,
303
+ );
304
+ }
305
+ }
306
+
307
+ if (check) {
308
+ if (drift) {
309
+ console.error("Run `pnpm gen:component-tokens` and commit the result.");
310
+ return 1;
311
+ }
312
+ console.log(
313
+ `OK — --zdc-* component token blocks up to date [${summaries.join("; ")}].`,
314
+ );
315
+ return 0;
316
+ }
317
+
318
+ if (!wroteAny) {
319
+ console.log(
320
+ `--zdc-* component token blocks already up to date [${summaries.join("; ")}]; no change.`,
321
+ );
322
+ }
323
+ return 0;
324
+ }
325
+
326
+ // Run the CLI only when executed directly, NOT when imported by tests (an
327
+ // import must not rewrite content.css as a side effect — it would break
328
+ // `pnpm test`). Compare REAL paths on both sides: when invoked through the
329
+ // pnpm bin shim (root `pnpm gen:/check:component-tokens`, b4push), argv[1] is
330
+ // the `node_modules/.bin/…` symlink, NOT the real workspace file, so a raw
331
+ // path-equality check is always false and main() would silently no-op — the
332
+ // drift guard would pass even on real drift. realpathSync resolves the shim/
333
+ // symlink to the real file on both sides so direct invocation is detected
334
+ // however the bin is launched.
335
+ function isDirectInvocation() {
336
+ if (!process.argv[1]) return false;
337
+ try {
338
+ return (
339
+ realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])
340
+ );
341
+ } catch {
342
+ return false;
343
+ }
344
+ }
345
+
346
+ if (isDirectInvocation()) {
347
+ process.exit(main());
348
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Assert that `ctx` is a fully-wired 2.0 ChromeContext (i.e. has `hostBindings`).
3
+ *
4
+ * Throws an actionable Error when the caller passes a bare pre-2.0 deps-bag
5
+ * instead of the unified ChromeContext the 2.0 factories require. This catches
6
+ * runtime crashes (`Cannot read properties of undefined (reading 'tagVocabulary')`)
7
+ * at the factory boundary instead of deep inside the component render.
8
+ *
9
+ * See packages/zudo-doc/API.md — "Migration Notes" for upgrade instructions.
10
+ */
11
+ export declare function assertChromeContext(ctx: unknown, factoryName: string): void;
@@ -0,0 +1,10 @@
1
+ function assertChromeContext(ctx, factoryName) {
2
+ if (ctx == null || ctx.hostBindings == null) {
3
+ throw new Error(
4
+ `\`${factoryName}\` received a context without \`hostBindings\` \u2014 the 2.0 factories take a single ChromeContext, not the pre-2.0 deps-bag. See packages/zudo-doc/API.md.`
5
+ );
6
+ }
7
+ }
8
+ export {
9
+ assertChromeContext
10
+ };
@@ -0,0 +1,56 @@
1
+ /** Grouping category for a component token (drives docs/registry sectioning). */
2
+ export type ComponentTokenCategory = "typography" | "shape" | "layout";
3
+ /**
4
+ * Which rendering surface a token's backing rule targets — drives WHERE the
5
+ * generator emits the rule:
6
+ * - `content` → the `.zd-content` typography surface; rule lands in the
7
+ * BEGIN/END block in `src/content.css` (shipped as `…/content.css`).
8
+ * - `chrome` → the app shell (header/footer/sidebar/toc/cards); rule lands
9
+ * in the BEGIN/END block in `src/features.css` (shipped as `…/features.css`).
10
+ * The two surfaces ship as separate stylesheets, so a token MUST declare its
11
+ * surface — the generator routes by it and fails loudly when it is absent.
12
+ */
13
+ export type ComponentTokenSurface = "content" | "chrome";
14
+ /**
15
+ * Union of every `--zdc-*` CSS custom property name in the registry.
16
+ * Provides autocomplete when a consumer iterates or references individual tokens.
17
+ * Must be kept in sync with the `cssVar` values in `COMPONENT_TOKENS` below.
18
+ */
19
+ export type ComponentTokenName = "--zdc-doc-title-font" | "--zdc-doc-title-weight" | "--zdc-doc-title-tracking" | "--zdc-doc-h2-font" | "--zdc-doc-h2-weight" | "--zdc-doc-h2-tracking" | "--zdc-doc-h3-weight" | "--zdc-doc-h4-weight" | "--zdc-doc-prose-font" | "--zdc-doc-link-decoration" | "--zdc-admonition-radius" | "--zdc-admonition-border-width" | "--zdc-card-radius" | "--zdc-content-max-width" | "--zdc-toc-width" | "--zdc-nav-active-indicator-color" | "--zdc-nav-active-weight";
20
+ export interface ComponentToken {
21
+ /**
22
+ * The CSS custom-property name the consumer redefines in `:root`, e.g.
23
+ * `--zdc-doc-title-font`. Always `--zdc-`-prefixed.
24
+ */
25
+ cssVar: `--zdc-${string}`;
26
+ /**
27
+ * The already-rendered, component-anchored selector the backing rule targets
28
+ * (the classes the component already emits — no JSX change). Tokens sharing a
29
+ * selector are emitted into a single CSS rule by the generator.
30
+ */
31
+ selector: string;
32
+ /** The CSS property this token drives, e.g. `font-family`. */
33
+ property: string;
34
+ /**
35
+ * Default value used as the `var(<cssVar>, <default>)` fallback. SHOULD chain
36
+ * to an existing design token or `inherit` so consumer `@theme` overrides flow
37
+ * through. Bare CSS literals (e.g. `4px`, `underline`) are allowed only when
38
+ * no design token exists for the property — document the exception in the
39
+ * snapshot test. The rendered default MUST be byte-identical to the pre-token
40
+ * output in every case.
41
+ */
42
+ default: string;
43
+ /** The component this token rebrands (e.g. `doc-title`). */
44
+ component: string;
45
+ /**
46
+ * Rendering surface the backing rule targets. The generator routes the rule
47
+ * to `content.css` (`content`) or `features.css` (`chrome`) by this field.
48
+ * Required — codegen throws if it is missing.
49
+ */
50
+ surface: ComponentTokenSurface;
51
+ /** Grouping category. */
52
+ category: ComponentTokenCategory;
53
+ /** Human-readable description for the registry/docs. */
54
+ description: string;
55
+ }
56
+ export declare const COMPONENT_TOKENS: ComponentToken[];