@olwiba/dx 0.0.27 → 0.0.29

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  <p align="center">
2
2
  <picture>
3
- <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/public/olwibaDX--light.gif" />
4
- <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/public/olwibaDX.gif" />
5
- <img src="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/public/olwibaDX.gif" alt="olwibaDX" style="width: 100%;" />
3
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/.github/assets/olwibaDX--light.gif" />
4
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/.github/assets/olwibaDX.gif" />
5
+ <img src="https://raw.githubusercontent.com/Olwiba/olwibaDX/master/.github/assets/olwibaDX.gif" alt="olwibaDX" style="width: 100%;" />
6
6
  </picture>
7
7
  </p>
8
8
 
@@ -78,7 +78,7 @@ Render an animated ASCII GIF from any text. This is what produced the README ban
78
78
  bunx @olwiba/dx ascii-gif \
79
79
  --text "olwibaDX" \
80
80
  --accent "DX" \
81
- --out ./public/olwibaDX.gif
81
+ --out ./.github/assets/olwibaDX.gif
82
82
  ```
83
83
 
84
84
  ### Asset Generator
package/dist/cli.js CHANGED
@@ -2402,13 +2402,14 @@ function checkEnv({
2402
2402
  const findings = [];
2403
2403
  let okCount = 0;
2404
2404
  for (const key of exampleKeys) {
2405
+ const exampleDeclaresValue = exampleEnv.keys.get(key) === true;
2406
+ const isOptional = optionalSet.has(key) || !exampleDeclaresValue;
2405
2407
  if (!actualEnv.keys.has(key)) {
2406
- if (!optionalSet.has(key)) findings.push({ kind: "missing", key });
2408
+ if (!isOptional) findings.push({ kind: "missing", key });
2407
2409
  continue;
2408
2410
  }
2409
2411
  const hasValue = actualEnv.keys.get(key) === true;
2410
- const exampleHasValue = exampleEnv.keys.get(key) === true;
2411
- if (!hasValue && exampleHasValue && !optionalSet.has(key)) {
2412
+ if (!hasValue && !isOptional) {
2412
2413
  findings.push({ kind: "empty", key });
2413
2414
  continue;
2414
2415
  }
@@ -2486,6 +2487,143 @@ var init_env_check = __esm({
2486
2487
  }
2487
2488
  });
2488
2489
 
2490
+ // src/docs-check.ts
2491
+ var docs_check_exports = {};
2492
+ __export(docs_check_exports, {
2493
+ checkDocs: () => checkDocs,
2494
+ formatDocsReport: () => formatDocsReport
2495
+ });
2496
+ function withoutCode(source) {
2497
+ return source.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, "").replace(/`[^`\n]*`/g, "");
2498
+ }
2499
+ function optOutIsExplained(source) {
2500
+ const match = source.match(/api-reference:\s*none\b([\s\S]{0,400})/);
2501
+ if (!match) return false;
2502
+ const tail = match[1] ?? "";
2503
+ const end = tail.indexOf("*/");
2504
+ const within = end === -1 ? tail : tail.slice(0, end);
2505
+ const words = within.replace(/^[\s—\-–:,.]+/, "").replace(/^\s*\*/gm, "").trim();
2506
+ return words.split(/\s+/).filter(Boolean).length >= 3;
2507
+ }
2508
+ function extractPropsExpressions(source) {
2509
+ const found = [];
2510
+ const marker = "props=";
2511
+ let index = source.indexOf(marker);
2512
+ while (index !== -1) {
2513
+ let cursor = index + marker.length;
2514
+ if (source[cursor] !== "{") {
2515
+ index = source.indexOf(marker, cursor);
2516
+ continue;
2517
+ }
2518
+ let depth = 0;
2519
+ const start = cursor;
2520
+ for (; cursor < source.length; cursor += 1) {
2521
+ if (source[cursor] === "{") depth += 1;
2522
+ else if (source[cursor] === "}") {
2523
+ depth -= 1;
2524
+ if (depth === 0) break;
2525
+ }
2526
+ }
2527
+ found.push(source.slice(start + 1, cursor));
2528
+ index = source.indexOf(marker, cursor);
2529
+ }
2530
+ return found;
2531
+ }
2532
+ function propsFailure(source) {
2533
+ for (const expression of extractPropsExpressions(source)) {
2534
+ let value;
2535
+ try {
2536
+ value = new Function(`return (${expression});`)();
2537
+ } catch (error) {
2538
+ return error instanceof Error ? error.message : String(error);
2539
+ }
2540
+ if (!Array.isArray(value)) return "props is not an array";
2541
+ for (const entry of value) {
2542
+ if (typeof entry?.name !== "string" || typeof entry?.type !== "string") {
2543
+ return `a prop entry is missing name or type: ${JSON.stringify(entry)}`;
2544
+ }
2545
+ }
2546
+ }
2547
+ return null;
2548
+ }
2549
+ function checkDocs(pages) {
2550
+ const findings = [];
2551
+ let okCount = 0;
2552
+ let exemptCount = 0;
2553
+ let skippedCount = 0;
2554
+ for (const page of pages) {
2555
+ const rendered = withoutCode(page.source);
2556
+ const hasPreview = PREVIEW.test(rendered);
2557
+ const hasReference = API_REFERENCE.test(rendered);
2558
+ const optedOut = OPT_OUT.test(rendered);
2559
+ const broken = hasReference ? propsFailure(page.source) : null;
2560
+ if (broken) {
2561
+ findings.push({ kind: "broken-props", file: page.file, reason: broken });
2562
+ continue;
2563
+ }
2564
+ if (optedOut && hasReference) {
2565
+ findings.push({ kind: "redundant-opt-out", file: page.file });
2566
+ continue;
2567
+ }
2568
+ if (!hasPreview) {
2569
+ skippedCount += 1;
2570
+ continue;
2571
+ }
2572
+ if (optedOut) {
2573
+ if (optOutIsExplained(rendered)) exemptCount += 1;
2574
+ else findings.push({ kind: "unexplained-opt-out", file: page.file });
2575
+ continue;
2576
+ }
2577
+ if (hasReference) okCount += 1;
2578
+ else findings.push({ kind: "missing", file: page.file });
2579
+ }
2580
+ return { findings, okCount, exemptCount, skippedCount };
2581
+ }
2582
+ function formatDocsReport(result) {
2583
+ const lines = [];
2584
+ for (const finding of result.findings) {
2585
+ if (finding.kind === "missing") {
2586
+ lines.push(
2587
+ ` ${finding.file}`,
2588
+ " Shows a preview but has no <APIReference>. Add one, or opt out with",
2589
+ " {/* api-reference: none \u2014 why */} if there is no public API here."
2590
+ );
2591
+ } else if (finding.kind === "broken-props") {
2592
+ lines.push(
2593
+ ` ${finding.file}`,
2594
+ ` Its <APIReference> props are not valid JavaScript, so the page will not render.`,
2595
+ ` ${finding.reason}`
2596
+ );
2597
+ } else if (finding.kind === "unexplained-opt-out") {
2598
+ lines.push(
2599
+ ` ${finding.file}`,
2600
+ " Opts out of the API reference without a reason. Write one after the marker."
2601
+ );
2602
+ } else {
2603
+ lines.push(
2604
+ ` ${finding.file}`,
2605
+ " Opts out of the API reference and then has one. Remove the stale marker."
2606
+ );
2607
+ }
2608
+ }
2609
+ const tally = `${result.okCount} documented, ${result.exemptCount} exempt, ${result.skippedCount} without a preview`;
2610
+ if (result.findings.length === 0) {
2611
+ return `All documentation pages that show a component document it (${tally}).`;
2612
+ }
2613
+ return `${result.findings.length} documentation page(s) need attention:
2614
+ ${lines.join("\n")}
2615
+
2616
+ ${tally}`;
2617
+ }
2618
+ var OPT_OUT, PREVIEW, API_REFERENCE;
2619
+ var init_docs_check = __esm({
2620
+ "src/docs-check.ts"() {
2621
+ OPT_OUT = /api-reference:\s*none\b/;
2622
+ PREVIEW = /<(Sandbox|ComponentPreview)\b/;
2623
+ API_REFERENCE = /<APIReference\b/;
2624
+ }
2625
+ });
2626
+
2489
2627
  // src/skills.ts
2490
2628
  function isSafeSkillSlug(slug) {
2491
2629
  return slug !== "." && slug !== ".." && /^[A-Za-z0-9._-]+$/.test(slug);
@@ -2512,9 +2650,11 @@ if (command === "skills" && subcommand === "install") {
2512
2650
  await runGenerateAssets();
2513
2651
  } else if (command === "env-check" || command === "env") {
2514
2652
  process.exitCode = await runEnvCheck();
2653
+ } else if (command === "docs-check") {
2654
+ process.exitCode = await runDocsCheck();
2515
2655
  } else {
2516
2656
  process.stdout.write(
2517
- "Usage:\n dx skills install [--source <url>] [--target claude|amp] [--all] [--name a,b,c]\n dx worktree cleanup [repo-name-or-path] [--repos-root <path>] [--remote <name>] [--dry-run] [--force] [--no-fetch]\n dx ascii-gif --text <text> --out <file.gif>\n dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>] [--og-component <svg-or-image-path>]\n"
2657
+ "Usage:\n dx skills install [--source <url>] [--target claude|amp] [--all] [--name a,b,c]\n dx worktree cleanup [repo-name-or-path] [--repos-root <path>] [--remote <name>] [--dry-run] [--force] [--no-fetch]\n dx ascii-gif --text <text> --out <file.gif>\n dx generate-assets --name <app> --icon <lucide-icon> --color <#hex> [--out <dir>] [--og-component <svg-or-image-path>]\n dx env-check [--example <.env.example>] [--file <.env>] [--optional a,b,c]\n dx docs-check [--dir <content/docs>]\n"
2518
2658
  );
2519
2659
  }
2520
2660
  async function runSkillsInstall() {
@@ -2772,6 +2912,38 @@ async function runEnvCheck() {
2772
2912
  process.stdout.write(`${formatEnvReport2(result)}${BREAK}`);
2773
2913
  return result.findings.length > 0 ? 1 : 0;
2774
2914
  }
2915
+ async function runDocsCheck() {
2916
+ const { readFileSync: readFileSync3, existsSync: existsSync2, readdirSync: readdirSync2 } = await import('fs');
2917
+ const { join: join4, relative: relative2, sep: sep2 } = await import('path');
2918
+ const { checkDocs: checkDocs2, formatDocsReport: formatDocsReport2 } = await Promise.resolve().then(() => (init_docs_check(), docs_check_exports));
2919
+ const flags = parseFlags(process.argv.slice(3));
2920
+ const dir = flags.dir ?? join4("content", "docs");
2921
+ if (!existsSync2(dir)) {
2922
+ process.stderr.write(
2923
+ `No documentation directory at ${dir}.${BREAK}Pass --dir if this project keeps its pages elsewhere.` + BREAK
2924
+ );
2925
+ return 1;
2926
+ }
2927
+ const files = [];
2928
+ const walk = (current) => {
2929
+ for (const entry of readdirSync2(current, { withFileTypes: true })) {
2930
+ const full = join4(current, entry.name);
2931
+ if (entry.isDirectory()) walk(full);
2932
+ else if (entry.name.endsWith(".mdx")) files.push(full);
2933
+ }
2934
+ };
2935
+ walk(dir);
2936
+ const result = checkDocs2(
2937
+ files.sort().map((file) => ({
2938
+ // Reported the same way on every platform, so a Windows run and a CI run
2939
+ // produce comparable output.
2940
+ file: relative2(process.cwd(), file).split(sep2).join("/"),
2941
+ source: readFileSync3(file, "utf8")
2942
+ }))
2943
+ );
2944
+ process.stdout.write(`${formatDocsReport2(result)}${BREAK}`);
2945
+ return result.findings.length > 0 ? 1 : 0;
2946
+ }
2775
2947
  async function readAllStdin() {
2776
2948
  const chunks = [];
2777
2949
  for await (const chunk of stdin) chunks.push(Buffer.from(chunk));
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Checks that every documentation page showing a component also documents it.
3
+ *
4
+ * A docs page that renders a preview is making a promise: this is a component
5
+ * you can use. The properties table is the other half of that promise, and it
6
+ * is the half that goes missing, because a page reads as finished long before
7
+ * anyone writes the props down. Fourteen marketing pages and an `ErrorPage`
8
+ * page that said in prose "takes no props" — it has seven — shipped that way
9
+ * and nobody noticed until someone opened one on a phone.
10
+ *
11
+ * So the rule is mechanical: a preview implies an `<APIReference>`. Pages that
12
+ * genuinely have no public surface to document opt out in the file, in a
13
+ * comment that has to carry a reason.
14
+ */
15
+ type DocsFinding = {
16
+ kind: 'missing';
17
+ file: string;
18
+ }
19
+ /** Opted out without saying why, which is how an opt-out becomes a habit. */
20
+ | {
21
+ kind: 'unexplained-opt-out';
22
+ file: string;
23
+ }
24
+ /** Opted out and then documented it anyway — the comment is stale. */
25
+ | {
26
+ kind: 'redundant-opt-out';
27
+ file: string;
28
+ }
29
+ /** The props array is not valid JavaScript, so the page cannot render. */
30
+ | {
31
+ kind: 'broken-props';
32
+ file: string;
33
+ reason: string;
34
+ };
35
+ interface DocsCheckResult {
36
+ findings: DocsFinding[];
37
+ /** Pages with a preview and a reference. Counted, not listed. */
38
+ okCount: number;
39
+ /** Pages with a preview and an explained opt-out. */
40
+ exemptCount: number;
41
+ /** Pages with no preview, which this says nothing about. */
42
+ skippedCount: number;
43
+ }
44
+ interface DocsPage {
45
+ /** Path as it should be reported — repository-relative, forward slashes. */
46
+ file: string;
47
+ source: string;
48
+ }
49
+ declare function checkDocs(pages: DocsPage[]): DocsCheckResult;
50
+ declare function formatDocsReport(result: DocsCheckResult): string;
51
+
52
+ export { type DocsCheckResult, type DocsFinding, type DocsPage, checkDocs, formatDocsReport };
@@ -0,0 +1,130 @@
1
+ // src/docs-check.ts
2
+ var OPT_OUT = /api-reference:\s*none\b/;
3
+ var PREVIEW = /<(Sandbox|ComponentPreview)\b/;
4
+ var API_REFERENCE = /<APIReference\b/;
5
+ function withoutCode(source) {
6
+ return source.replace(/^[ \t]*(`{3,}|~{3,})[\s\S]*?^[ \t]*\1[ \t]*$/gm, "").replace(/`[^`\n]*`/g, "");
7
+ }
8
+ function optOutIsExplained(source) {
9
+ const match = source.match(/api-reference:\s*none\b([\s\S]{0,400})/);
10
+ if (!match) return false;
11
+ const tail = match[1] ?? "";
12
+ const end = tail.indexOf("*/");
13
+ const within = end === -1 ? tail : tail.slice(0, end);
14
+ const words = within.replace(/^[\s—\-–:,.]+/, "").replace(/^\s*\*/gm, "").trim();
15
+ return words.split(/\s+/).filter(Boolean).length >= 3;
16
+ }
17
+ function extractPropsExpressions(source) {
18
+ const found = [];
19
+ const marker = "props=";
20
+ let index = source.indexOf(marker);
21
+ while (index !== -1) {
22
+ let cursor = index + marker.length;
23
+ if (source[cursor] !== "{") {
24
+ index = source.indexOf(marker, cursor);
25
+ continue;
26
+ }
27
+ let depth = 0;
28
+ const start = cursor;
29
+ for (; cursor < source.length; cursor += 1) {
30
+ if (source[cursor] === "{") depth += 1;
31
+ else if (source[cursor] === "}") {
32
+ depth -= 1;
33
+ if (depth === 0) break;
34
+ }
35
+ }
36
+ found.push(source.slice(start + 1, cursor));
37
+ index = source.indexOf(marker, cursor);
38
+ }
39
+ return found;
40
+ }
41
+ function propsFailure(source) {
42
+ for (const expression of extractPropsExpressions(source)) {
43
+ let value;
44
+ try {
45
+ value = new Function(`return (${expression});`)();
46
+ } catch (error) {
47
+ return error instanceof Error ? error.message : String(error);
48
+ }
49
+ if (!Array.isArray(value)) return "props is not an array";
50
+ for (const entry of value) {
51
+ if (typeof entry?.name !== "string" || typeof entry?.type !== "string") {
52
+ return `a prop entry is missing name or type: ${JSON.stringify(entry)}`;
53
+ }
54
+ }
55
+ }
56
+ return null;
57
+ }
58
+ function checkDocs(pages) {
59
+ const findings = [];
60
+ let okCount = 0;
61
+ let exemptCount = 0;
62
+ let skippedCount = 0;
63
+ for (const page of pages) {
64
+ const rendered = withoutCode(page.source);
65
+ const hasPreview = PREVIEW.test(rendered);
66
+ const hasReference = API_REFERENCE.test(rendered);
67
+ const optedOut = OPT_OUT.test(rendered);
68
+ const broken = hasReference ? propsFailure(page.source) : null;
69
+ if (broken) {
70
+ findings.push({ kind: "broken-props", file: page.file, reason: broken });
71
+ continue;
72
+ }
73
+ if (optedOut && hasReference) {
74
+ findings.push({ kind: "redundant-opt-out", file: page.file });
75
+ continue;
76
+ }
77
+ if (!hasPreview) {
78
+ skippedCount += 1;
79
+ continue;
80
+ }
81
+ if (optedOut) {
82
+ if (optOutIsExplained(rendered)) exemptCount += 1;
83
+ else findings.push({ kind: "unexplained-opt-out", file: page.file });
84
+ continue;
85
+ }
86
+ if (hasReference) okCount += 1;
87
+ else findings.push({ kind: "missing", file: page.file });
88
+ }
89
+ return { findings, okCount, exemptCount, skippedCount };
90
+ }
91
+ function formatDocsReport(result) {
92
+ const lines = [];
93
+ for (const finding of result.findings) {
94
+ if (finding.kind === "missing") {
95
+ lines.push(
96
+ ` ${finding.file}`,
97
+ " Shows a preview but has no <APIReference>. Add one, or opt out with",
98
+ " {/* api-reference: none \u2014 why */} if there is no public API here."
99
+ );
100
+ } else if (finding.kind === "broken-props") {
101
+ lines.push(
102
+ ` ${finding.file}`,
103
+ ` Its <APIReference> props are not valid JavaScript, so the page will not render.`,
104
+ ` ${finding.reason}`
105
+ );
106
+ } else if (finding.kind === "unexplained-opt-out") {
107
+ lines.push(
108
+ ` ${finding.file}`,
109
+ " Opts out of the API reference without a reason. Write one after the marker."
110
+ );
111
+ } else {
112
+ lines.push(
113
+ ` ${finding.file}`,
114
+ " Opts out of the API reference and then has one. Remove the stale marker."
115
+ );
116
+ }
117
+ }
118
+ const tally = `${result.okCount} documented, ${result.exemptCount} exempt, ${result.skippedCount} without a preview`;
119
+ if (result.findings.length === 0) {
120
+ return `All documentation pages that show a component document it (${tally}).`;
121
+ }
122
+ return `${result.findings.length} documentation page(s) need attention:
123
+ ${lines.join("\n")}
124
+
125
+ ${tally}`;
126
+ }
127
+
128
+ export { checkDocs, formatDocsReport };
129
+ //# sourceMappingURL=docs-check.js.map
130
+ //# sourceMappingURL=docs-check.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/docs-check.ts"],"names":[],"mappings":"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","file":"docs-check.js","sourcesContent":["/**\n * Checks that every documentation page showing a component also documents it.\n *\n * A docs page that renders a preview is making a promise: this is a component\n * you can use. The properties table is the other half of that promise, and it\n * is the half that goes missing, because a page reads as finished long before\n * anyone writes the props down. Fourteen marketing pages and an `ErrorPage`\n * page that said in prose \"takes no props\" — it has seven — shipped that way\n * and nobody noticed until someone opened one on a phone.\n *\n * So the rule is mechanical: a preview implies an `<APIReference>`. Pages that\n * genuinely have no public surface to document opt out in the file, in a\n * comment that has to carry a reason.\n */\n\n/** Marks a page as having nothing to document, and says why. */\nconst OPT_OUT = /api-reference:\\s*none\\b/;\n\n/** What counts as showing a component off. */\nconst PREVIEW = /<(Sandbox|ComponentPreview)\\b/;\n\nconst API_REFERENCE = /<APIReference\\b/;\n\n/**\n * Drops fenced and inline code before looking for markers.\n *\n * A page that quotes `<Sandbox>` in a table, or shows what the opt-out comment\n * looks like inside a fence, is describing the markers rather than using them —\n * nothing is rendered and nothing is claimed. Matching the raw source made the\n * checker's own documentation page its first false positive, which is the sort\n * of rule that gets switched off rather than fixed.\n *\n * Fences go first so an unpaired backtick inside one cannot swallow the rest of\n * the file.\n */\nfunction withoutCode(source: string): string {\n return source.replace(/^[ \\t]*(`{3,}|~{3,})[\\s\\S]*?^[ \\t]*\\1[ \\t]*$/gm, '').replace(/`[^`\\n]*`/g, '');\n}\n\nexport type DocsFinding =\n | { kind: 'missing'; file: string }\n /** Opted out without saying why, which is how an opt-out becomes a habit. */\n | { kind: 'unexplained-opt-out'; file: string }\n /** Opted out and then documented it anyway — the comment is stale. */\n | { kind: 'redundant-opt-out'; file: string }\n /** The props array is not valid JavaScript, so the page cannot render. */\n | { kind: 'broken-props'; file: string; reason: string };\n\nexport interface DocsCheckResult {\n findings: DocsFinding[];\n /** Pages with a preview and a reference. Counted, not listed. */\n okCount: number;\n /** Pages with a preview and an explained opt-out. */\n exemptCount: number;\n /** Pages with no preview, which this says nothing about. */\n skippedCount: number;\n}\n\nexport interface DocsPage {\n /** Path as it should be reported — repository-relative, forward slashes. */\n file: string;\n source: string;\n}\n\n/**\n * An opt-out needs a reason after the marker, on the same line or the ones\n * following it inside the same comment. `api-reference: none` on its own is\n * rejected: the point of the escape hatch is that using it is a decision\n * somebody wrote down.\n */\nfunction optOutIsExplained(source: string): boolean {\n const match = source.match(/api-reference:\\s*none\\b([\\s\\S]{0,400})/);\n if (!match) return false;\n\n const tail = match[1] ?? '';\n // Stop at the end of the comment the marker sits in, so prose further down\n // the page cannot pass for a justification.\n const end = tail.indexOf('*/');\n const within = end === -1 ? tail : tail.slice(0, end);\n\n // Strip the punctuation an author uses to introduce the reason, plus the\n // comment gutter, and see whether any words are left.\n const words = within\n .replace(/^[\\s—\\-–:,.]+/, '')\n .replace(/^\\s*\\*/gm, '')\n .trim();\n\n return words.split(/\\s+/).filter(Boolean).length >= 3;\n}\n\n/**\n * Every balanced `{...}` following a `props=` attribute.\n *\n * Matched by counting braces rather than by regex: the prop tables contain\n * object literals, so the first `}` is nowhere near the end of the attribute.\n */\nfunction extractPropsExpressions(source: string): string[] {\n const found: string[] = [];\n const marker = 'props=';\n let index = source.indexOf(marker);\n\n while (index !== -1) {\n let cursor = index + marker.length;\n if (source[cursor] !== '{') {\n index = source.indexOf(marker, cursor);\n continue;\n }\n\n let depth = 0;\n const start = cursor;\n for (; cursor < source.length; cursor += 1) {\n if (source[cursor] === '{') depth += 1;\n else if (source[cursor] === '}') {\n depth -= 1;\n if (depth === 0) break;\n }\n }\n\n found.push(source.slice(start + 1, cursor));\n index = source.indexOf(marker, cursor);\n }\n\n return found;\n}\n\n/**\n * Confirms the prop table is JavaScript that runs.\n *\n * MDX does not evaluate a page's expressions until something renders it, so a\n * mis-escaped quote inside a description is invisible to every build step and\n * only shows up as a blank page. Evaluating the array here is the cheapest way\n * to find that out — the source being run is the repository's own.\n */\nfunction propsFailure(source: string): string | null {\n for (const expression of extractPropsExpressions(source)) {\n let value: unknown;\n try {\n value = new Function(`return (${expression});`)();\n } catch (error) {\n return error instanceof Error ? error.message : String(error);\n }\n\n if (!Array.isArray(value)) return 'props is not an array';\n\n for (const entry of value as Array<Record<string, unknown>>) {\n if (typeof entry?.name !== 'string' || typeof entry?.type !== 'string') {\n return `a prop entry is missing name or type: ${JSON.stringify(entry)}`;\n }\n }\n }\n\n return null;\n}\n\nexport function checkDocs(pages: DocsPage[]): DocsCheckResult {\n const findings: DocsFinding[] = [];\n let okCount = 0;\n let exemptCount = 0;\n let skippedCount = 0;\n\n for (const page of pages) {\n // Markers are looked for in what the page renders, not in what it quotes.\n const rendered = withoutCode(page.source);\n const hasPreview = PREVIEW.test(rendered);\n const hasReference = API_REFERENCE.test(rendered);\n const optedOut = OPT_OUT.test(rendered);\n\n // Checked before anything else: a page whose table cannot run has a worse\n // problem than a page that has no table. Read from the raw source: a props\n // table is an expression MDX evaluates, and stripping inline code would\n // change it.\n const broken = hasReference ? propsFailure(page.source) : null;\n if (broken) {\n findings.push({ kind: 'broken-props', file: page.file, reason: broken });\n continue;\n }\n\n if (optedOut && hasReference) {\n findings.push({ kind: 'redundant-opt-out', file: page.file });\n continue;\n }\n\n if (!hasPreview) {\n // Index pages, guides, concept pages. Nothing is being shown off, so\n // there is nothing this can reasonably demand.\n skippedCount += 1;\n continue;\n }\n\n if (optedOut) {\n if (optOutIsExplained(rendered)) exemptCount += 1;\n else findings.push({ kind: 'unexplained-opt-out', file: page.file });\n continue;\n }\n\n if (hasReference) okCount += 1;\n else findings.push({ kind: 'missing', file: page.file });\n }\n\n return { findings, okCount, exemptCount, skippedCount };\n}\n\nexport function formatDocsReport(result: DocsCheckResult): string {\n const lines: string[] = [];\n\n for (const finding of result.findings) {\n if (finding.kind === 'missing') {\n lines.push(\n ` ${finding.file}`,\n ' Shows a preview but has no <APIReference>. Add one, or opt out with',\n ' {/* api-reference: none — why */} if there is no public API here.',\n );\n } else if (finding.kind === 'broken-props') {\n lines.push(\n ` ${finding.file}`,\n ` Its <APIReference> props are not valid JavaScript, so the page will not render.`,\n ` ${finding.reason}`,\n );\n } else if (finding.kind === 'unexplained-opt-out') {\n lines.push(\n ` ${finding.file}`,\n ' Opts out of the API reference without a reason. Write one after the marker.',\n );\n } else {\n lines.push(\n ` ${finding.file}`,\n ' Opts out of the API reference and then has one. Remove the stale marker.',\n );\n }\n }\n\n const tally =\n `${result.okCount} documented, ${result.exemptCount} exempt, ` +\n `${result.skippedCount} without a preview`;\n\n if (result.findings.length === 0) {\n return `All documentation pages that show a component document it (${tally}).`;\n }\n\n return (\n `${result.findings.length} documentation page(s) need attention:\\n` +\n `${lines.join('\\n')}\\n\\n${tally}`\n );\n}\n"]}
package/dist/env-check.js CHANGED
@@ -41,13 +41,14 @@ function checkEnv({
41
41
  const findings = [];
42
42
  let okCount = 0;
43
43
  for (const key of exampleKeys) {
44
+ const exampleDeclaresValue = exampleEnv.keys.get(key) === true;
45
+ const isOptional = optionalSet.has(key) || !exampleDeclaresValue;
44
46
  if (!actualEnv.keys.has(key)) {
45
- if (!optionalSet.has(key)) findings.push({ kind: "missing", key });
47
+ if (!isOptional) findings.push({ kind: "missing", key });
46
48
  continue;
47
49
  }
48
50
  const hasValue = actualEnv.keys.get(key) === true;
49
- const exampleHasValue = exampleEnv.keys.get(key) === true;
50
- if (!hasValue && exampleHasValue && !optionalSet.has(key)) {
51
+ if (!hasValue && !isOptional) {
51
52
  findings.push({ kind: "empty", key });
52
53
  continue;
53
54
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/env-check.ts"],"names":[],"mappings":"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","file":"env-check.js","sourcesContent":["/**\n * Compares a real environment against the example that documents it.\n *\n * Pure string comparison, deliberately. The input is a file full of live\n * credentials, so it is never sent anywhere, never written to disk, and never\n * echoed back — the report names keys and says nothing about values.\n *\n * `.env.example` is the schema. Every repository already has one, which is what\n * makes this usable outside the repositories that have a typed schema, and\n * those are exactly the ones where environments have been rotting unnoticed.\n */\n\nexport type EnvFinding =\n | { kind: 'missing'; key: string }\n | { kind: 'unknown'; key: string }\n | { kind: 'renamed'; key: string; looksLike: string }\n | { kind: 'empty'; key: string }\n | { kind: 'malformed'; line: number; text: string }\n | { kind: 'duplicate'; key: string };\n\nexport interface EnvCheckResult {\n findings: EnvFinding[];\n /** Keys present in both, non-empty. Counted, never listed. */\n okCount: number;\n exampleCount: number;\n actualCount: number;\n}\n\ninterface ParsedEnv {\n /** Key to whether it has a non-empty value. Values themselves are discarded. */\n keys: Map<string, boolean>;\n duplicates: string[];\n malformed: Array<{ line: number; text: string }>;\n}\n\n/**\n * Reads keys and discards values immediately.\n *\n * The value never leaves this function, so nothing downstream can leak one by\n * accident — including a future change to the report format.\n */\nexport function parseEnv(source: string): ParsedEnv {\n const keys = new Map<string, boolean>();\n const duplicates: string[] = [];\n const malformed: Array<{ line: number; text: string }> = [];\n\n source.split(/\\r?\\n/).forEach((raw, index) => {\n const line = raw.trim();\n if (line === '' || line.startsWith('#')) return;\n\n // `export FOO=bar` is valid in a shell-sourced file.\n const withoutExport = line.replace(/^export\\s+/, '');\n const eq = withoutExport.indexOf('=');\n\n if (eq <= 0) {\n // Reported by line number without the text where it could hold a value:\n // a line missing its `=` may still be a pasted secret.\n malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });\n return;\n }\n\n const key = withoutExport.slice(0, eq).trim();\n const value = withoutExport.slice(eq + 1).trim();\n\n if (keys.has(key)) duplicates.push(key);\n // A later assignment wins in most loaders, so the last one decides.\n keys.set(key, value !== '' && value !== '\"\"' && value !== \"''\");\n });\n\n return { keys, duplicates, malformed };\n}\n\n/**\n * Whether an unrecognised key looks like a renamed version of a known one.\n *\n * This is the finding that matters. A stale key reads as configured — someone\n * scanning the file sees the name they expect in all but a prefix and stops\n * looking — so reporting it as a generic unknown would not tell anyone that the\n * setting it was meant to carry is unset.\n */\nfunction findRenameCandidate(unknownKey: string, knownKeys: string[]): string | undefined {\n const normalise = (key: string) => key.replace(/[^A-Z0-9]/gi, '').toUpperCase();\n const target = normalise(unknownKey);\n\n return knownKeys.find((known) => {\n const candidate = normalise(known);\n if (candidate === target) return true;\n // A prefix or suffix on an otherwise identical name: VITE_FOO against FOO.\n return (\n candidate.length >= 6 &&\n (target.endsWith(candidate) || target.startsWith(candidate))\n );\n });\n}\n\nexport function checkEnv({\n example,\n actual,\n /** Keys allowed to be absent, e.g. optional credentials. */\n optional = [],\n}: {\n example: string;\n actual: string;\n optional?: string[];\n}): EnvCheckResult {\n const exampleEnv = parseEnv(example);\n const actualEnv = parseEnv(actual);\n const optionalSet = new Set(optional);\n\n const exampleKeys = [...exampleEnv.keys.keys()];\n const findings: EnvFinding[] = [];\n let okCount = 0;\n\n for (const key of exampleKeys) {\n if (!actualEnv.keys.has(key)) {\n if (!optionalSet.has(key)) findings.push({ kind: 'missing', key });\n continue;\n }\n\n const hasValue = actualEnv.keys.get(key) === true;\n const exampleHasValue = exampleEnv.keys.get(key) === true;\n\n // Blank where the example shows a value: the example is demonstrating that\n // something belongs there. Blank in both is a deliberate opt-out.\n if (!hasValue && exampleHasValue && !optionalSet.has(key)) {\n findings.push({ kind: 'empty', key });\n continue;\n }\n\n okCount += 1;\n }\n\n for (const key of actualEnv.keys.keys()) {\n if (exampleEnv.keys.has(key)) continue;\n\n const looksLike = findRenameCandidate(key, exampleKeys);\n findings.push(looksLike ? { kind: 'renamed', key, looksLike } : { kind: 'unknown', key });\n }\n\n for (const key of actualEnv.duplicates) findings.push({ kind: 'duplicate', key });\n for (const line of actualEnv.malformed) findings.push({ kind: 'malformed', ...line });\n\n return {\n findings,\n okCount,\n exampleCount: exampleEnv.keys.size,\n actualCount: actualEnv.keys.size,\n };\n}\n\nconst ORDER: EnvFinding['kind'][] = [\n 'renamed',\n 'missing',\n 'empty',\n 'malformed',\n 'duplicate',\n 'unknown',\n];\n\nconst LABELS: Record<EnvFinding['kind'], string> = {\n renamed: 'Renamed — set under a name nothing reads',\n missing: 'Missing',\n empty: 'Present but empty',\n malformed: 'Malformed line',\n duplicate: 'Set more than once',\n unknown: 'Unknown — not in the example',\n};\n\n/** Formats a report. Contains key names and counts only, never a value. */\nexport function formatEnvReport(result: EnvCheckResult): string {\n const lines: string[] = [];\n lines.push(\n ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`,\n );\n\n if (result.findings.length === 0) {\n lines.push('\\nPASS — environment matches the example.');\n return lines.join('\\n');\n }\n\n for (const kind of ORDER) {\n const group = result.findings.filter((finding) => finding.kind === kind);\n if (group.length === 0) continue;\n\n lines.push(`\\n${LABELS[kind]}:`);\n for (const finding of group) {\n if (finding.kind === 'renamed') {\n lines.push(` ✗ ${finding.key} → did you mean ${finding.looksLike}?`);\n } else if (finding.kind === 'malformed') {\n lines.push(` ✗ line ${finding.line}: ${finding.text}…`);\n } else {\n lines.push(` ✗ ${finding.key}`);\n }\n }\n }\n\n // Renames are called out because they are the ones that read as configured.\n const renamed = result.findings.filter((finding) => finding.kind === 'renamed').length;\n if (renamed > 0) {\n lines.push(\n `\\n${renamed} key(s) look renamed. The setting they were meant to carry is unset,` +\n '\\nwhich usually means a default is in force that nobody chose.',\n );\n }\n\n lines.push(`\\nFAIL — ${result.findings.length} finding(s).`);\n return lines.join('\\n');\n}\n"]}
1
+ {"version":3,"sources":["../src/env-check.ts"],"names":[],"mappings":"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","file":"env-check.js","sourcesContent":["/**\n * Compares a real environment against the example that documents it.\n *\n * Pure string comparison, deliberately. The input is a file full of live\n * credentials, so it is never sent anywhere, never written to disk, and never\n * echoed back — the report names keys and says nothing about values.\n *\n * `.env.example` is the schema. Every repository already has one, which is what\n * makes this usable outside the repositories that have a typed schema, and\n * those are exactly the ones where environments have been rotting unnoticed.\n */\n\nexport type EnvFinding =\n | { kind: 'missing'; key: string }\n | { kind: 'unknown'; key: string }\n | { kind: 'renamed'; key: string; looksLike: string }\n | { kind: 'empty'; key: string }\n | { kind: 'malformed'; line: number; text: string }\n | { kind: 'duplicate'; key: string };\n\nexport interface EnvCheckResult {\n findings: EnvFinding[];\n /** Keys present in both, non-empty. Counted, never listed. */\n okCount: number;\n exampleCount: number;\n actualCount: number;\n}\n\ninterface ParsedEnv {\n /** Key to whether it has a non-empty value. Values themselves are discarded. */\n keys: Map<string, boolean>;\n duplicates: string[];\n malformed: Array<{ line: number; text: string }>;\n}\n\n/**\n * Reads keys and discards values immediately.\n *\n * The value never leaves this function, so nothing downstream can leak one by\n * accident — including a future change to the report format.\n */\nexport function parseEnv(source: string): ParsedEnv {\n const keys = new Map<string, boolean>();\n const duplicates: string[] = [];\n const malformed: Array<{ line: number; text: string }> = [];\n\n source.split(/\\r?\\n/).forEach((raw, index) => {\n const line = raw.trim();\n if (line === '' || line.startsWith('#')) return;\n\n // `export FOO=bar` is valid in a shell-sourced file.\n const withoutExport = line.replace(/^export\\s+/, '');\n const eq = withoutExport.indexOf('=');\n\n if (eq <= 0) {\n // Reported by line number without the text where it could hold a value:\n // a line missing its `=` may still be a pasted secret.\n malformed.push({ line: index + 1, text: withoutExport.slice(0, 24) });\n return;\n }\n\n const key = withoutExport.slice(0, eq).trim();\n const value = withoutExport.slice(eq + 1).trim();\n\n if (keys.has(key)) duplicates.push(key);\n // A later assignment wins in most loaders, so the last one decides.\n keys.set(key, value !== '' && value !== '\"\"' && value !== \"''\");\n });\n\n return { keys, duplicates, malformed };\n}\n\n/**\n * Whether an unrecognised key looks like a renamed version of a known one.\n *\n * This is the finding that matters. A stale key reads as configured — someone\n * scanning the file sees the name they expect in all but a prefix and stops\n * looking — so reporting it as a generic unknown would not tell anyone that the\n * setting it was meant to carry is unset.\n */\nfunction findRenameCandidate(unknownKey: string, knownKeys: string[]): string | undefined {\n const normalise = (key: string) => key.replace(/[^A-Z0-9]/gi, '').toUpperCase();\n const target = normalise(unknownKey);\n\n return knownKeys.find((known) => {\n const candidate = normalise(known);\n if (candidate === target) return true;\n // A prefix or suffix on an otherwise identical name: VITE_FOO against FOO.\n return (\n candidate.length >= 6 &&\n (target.endsWith(candidate) || target.startsWith(candidate))\n );\n });\n}\n\nexport function checkEnv({\n example,\n actual,\n /** Keys allowed to be absent, e.g. optional credentials. */\n optional = [],\n}: {\n example: string;\n actual: string;\n optional?: string[];\n}): EnvCheckResult {\n const exampleEnv = parseEnv(example);\n const actualEnv = parseEnv(actual);\n const optionalSet = new Set(optional);\n\n const exampleKeys = [...exampleEnv.keys.keys()];\n const findings: EnvFinding[] = [];\n let okCount = 0;\n\n for (const key of exampleKeys) {\n // A blank in the example is the example saying this slot is optional — it\n // is showing the name without claiming a value belongs there. Absent then\n // means the same as blank, and reporting it drowns the real findings in\n // unset credentials for services this deployment does not use.\n const exampleDeclaresValue = exampleEnv.keys.get(key) === true;\n const isOptional = optionalSet.has(key) || !exampleDeclaresValue;\n\n if (!actualEnv.keys.has(key)) {\n if (!isOptional) findings.push({ kind: 'missing', key });\n continue;\n }\n\n const hasValue = actualEnv.keys.get(key) === true;\n\n // Blank where the example shows a value: the example is demonstrating that\n // something belongs there. Blank in both is a deliberate opt-out.\n if (!hasValue && !isOptional) {\n findings.push({ kind: 'empty', key });\n continue;\n }\n\n okCount += 1;\n }\n\n for (const key of actualEnv.keys.keys()) {\n if (exampleEnv.keys.has(key)) continue;\n\n const looksLike = findRenameCandidate(key, exampleKeys);\n findings.push(looksLike ? { kind: 'renamed', key, looksLike } : { kind: 'unknown', key });\n }\n\n for (const key of actualEnv.duplicates) findings.push({ kind: 'duplicate', key });\n for (const line of actualEnv.malformed) findings.push({ kind: 'malformed', ...line });\n\n return {\n findings,\n okCount,\n exampleCount: exampleEnv.keys.size,\n actualCount: actualEnv.keys.size,\n };\n}\n\nconst ORDER: EnvFinding['kind'][] = [\n 'renamed',\n 'missing',\n 'empty',\n 'malformed',\n 'duplicate',\n 'unknown',\n];\n\nconst LABELS: Record<EnvFinding['kind'], string> = {\n renamed: 'Renamed — set under a name nothing reads',\n missing: 'Missing',\n empty: 'Present but empty',\n malformed: 'Malformed line',\n duplicate: 'Set more than once',\n unknown: 'Unknown — not in the example',\n};\n\n/** Formats a report. Contains key names and counts only, never a value. */\nexport function formatEnvReport(result: EnvCheckResult): string {\n const lines: string[] = [];\n lines.push(\n ` ${result.actualCount} keys checked against ${result.exampleCount} in the example, ${result.okCount} correct`,\n );\n\n if (result.findings.length === 0) {\n lines.push('\\nPASS — environment matches the example.');\n return lines.join('\\n');\n }\n\n for (const kind of ORDER) {\n const group = result.findings.filter((finding) => finding.kind === kind);\n if (group.length === 0) continue;\n\n lines.push(`\\n${LABELS[kind]}:`);\n for (const finding of group) {\n if (finding.kind === 'renamed') {\n lines.push(` ✗ ${finding.key} → did you mean ${finding.looksLike}?`);\n } else if (finding.kind === 'malformed') {\n lines.push(` ✗ line ${finding.line}: ${finding.text}…`);\n } else {\n lines.push(` ✗ ${finding.key}`);\n }\n }\n }\n\n // Renames are called out because they are the ones that read as configured.\n const renamed = result.findings.filter((finding) => finding.kind === 'renamed').length;\n if (renamed > 0) {\n lines.push(\n `\\n${renamed} key(s) look renamed. The setting they were meant to carry is unset,` +\n '\\nwhich usually means a default is in force that nobody chose.',\n );\n }\n\n lines.push(`\\nFAIL — ${result.findings.length} finding(s).`);\n return lines.join('\\n');\n}\n"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@olwiba/dx",
3
- "version": "0.0.27",
3
+ "version": "0.0.29",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -52,16 +52,46 @@
52
52
  "build": "tsup",
53
53
  "dev": "tsup --watch",
54
54
  "test": "bun test",
55
- "tsc": "tsc --noEmit"
55
+ "tsc": "tsc --noEmit",
56
+ "web:build": "fumadocs-mdx && vite build",
57
+ "web:dev": "fumadocs-mdx && vite dev",
58
+ "start": "bun run server.ts",
59
+ "types:check": "fumadocs-mdx && tsc --noEmit && tsc --noEmit -p tsconfig.site.json",
60
+ "presets:generate": "bun scripts/generate-env-presets.ts",
61
+ "docs:check": "bun src/cli.ts docs-check",
62
+ "env:check": "bun src/cli.ts env-check",
63
+ "showcase:build": "bun scripts/build-showcase.ts"
56
64
  },
57
65
  "devDependencies": {
66
+ "@olwiba/cn": "0.1.41",
67
+ "@olwiba/docs": "0.1.47",
68
+ "@tailwindcss/vite": "^4.1.18",
69
+ "@tanstack/react-router": "1.154.8",
70
+ "@tanstack/react-start": "1.154.8",
71
+ "@types/mdx": "^2.0.13",
58
72
  "@types/node": "^22.5.4",
73
+ "@types/react": "^19.0.8",
74
+ "@types/react-dom": "^19.0.3",
75
+ "@vitejs/plugin-react": "^4.6.0",
59
76
  "eslint": "^9.39.5",
77
+ "fumadocs-core": "^16.4.7",
78
+ "fumadocs-mdx": "^14.2.6",
79
+ "fumadocs-ui": "^16.4.7",
80
+ "lucide-react": "^0.562.0",
60
81
  "lucide-static": "^0.488.0",
82
+ "next-themes": "^0.4.6",
61
83
  "puppeteer-core": "^25.9.0",
84
+ "react": "^19.0.0",
85
+ "react-dom": "^19.0.0",
86
+ "rehype-pretty-code": "^0.14.0",
62
87
  "sharp": "^0.35.0",
88
+ "shiki": "^3.0.0",
89
+ "tailwindcss": "^4.1.18",
63
90
  "tsup": "^8.4.0",
64
- "typescript": "^5.7.2"
91
+ "tw-animate-css": "^1.4.0",
92
+ "typescript": "^5.7.2",
93
+ "vite": "^7.3.1",
94
+ "vite-tsconfig-paths": "^5.1.4"
65
95
  },
66
96
  "peerDependencies": {
67
97
  "@typescript-eslint/eslint-plugin": ">=8.0.0",