contentbit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AA2JnC,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,+EAA+E;AAC/E,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,YAAY,EACrB,EAAE,EAAE,EAAE,GACL,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAkB3E"}
1
+ {"version":3,"file":"agents.d.ts","sourceRoot":"","sources":["../../src/commands/agents.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAyMnC,MAAM,WAAW,YAAY;IAC3B,8EAA8E;IAC9E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,oDAAoD;IACpD,QAAQ,CAAC,EAAE,OAAO,CAAA;CACnB;AAED,+EAA+E;AAC/E,wBAAsB,uBAAuB,CAC3C,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,YAAY,EACrB,EAAE,EAAE,EAAE,GACL,OAAO,CAAC,IAAI,CAAC,CA6Bf;AAED,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAkB3E"}
@@ -7,7 +7,7 @@ import { parseArgs } from 'node:util';
7
7
  // registry stays the single source of truth and nothing can drift. Bump the
8
8
  // frontmatter version when a template changes; `contentbit agents` re-runs
9
9
  // overwrite in place.
10
- const TEMPLATE_VERSION = 1;
10
+ const TEMPLATE_VERSION = 2;
11
11
  const AUTHOR_SKILL = `---
12
12
  name: contentbit-author
13
13
  description: |
@@ -30,6 +30,8 @@ Check \`package.json\` for a \`content:check\` script. It holds the canonical
30
30
  validate invocation for this project: the content glob and, if present, the
31
31
  \`--registry <path>\` flag pointing at custom block definitions. Reuse both
32
32
  below. No script? Default to \`content/**/*.md\` with no \`--registry\` flag.
33
+ If the project has a \`content:links\` script, use it for the internal-link
34
+ index; otherwise run \`contentbit links <content glob>\` directly.
33
35
 
34
36
  ## The loop
35
37
 
@@ -44,7 +46,19 @@ below. No script? Default to \`content/**/*.md\` with no \`--registry\` flag.
44
46
 
45
47
  2. **Write the document.** Plain Markdown everywhere; blocks only where the
46
48
  guide's use-when guidance fits. Keep frontmatter consistent with sibling
47
- documents in the same folder.
49
+ documents in the same folder. If sibling documents use \`slug\`, \`linksTo\`,
50
+ \`aliases\`, or \`keywords\`, run the link index first:
51
+
52
+ \`\`\`sh
53
+ contentbit links <content glob>
54
+ \`\`\`
55
+
56
+ Read \`.contentbit/link-index.json\` to pick existing slugs and related
57
+ pages. Author only \`slug\`, \`linksTo\`, \`aliases\`, and \`keywords\` in
58
+ frontmatter; never write derived \`linkedFrom\` into source files. When
59
+ creating a linked page, include \`keywords.primary\` and
60
+ \`keywords.secondary\` with search-intent phrases that would help future
61
+ agents choose this page as a \`linksTo\` target.
48
62
 
49
63
  3. **Validate and fix until clean:**
50
64
 
@@ -54,8 +68,19 @@ below. No script? Default to \`content/**/*.md\` with no \`--registry\` flag.
54
68
 
55
69
  Diagnostics print to stderr as \`file:line:col severity CODE message\`, often
56
70
  with a \`hint:\` line suggesting the fix. Exit 0 means clean; exit 1 means
57
- errors remain. Fix every diagnostic and re-run. Never finish with a failing
58
- validate.
71
+ errors remain. If the document has link frontmatter, validate the full
72
+ content glob so cross-file links are checked against the whole graph. Fix
73
+ every diagnostic and re-run. Never finish with a failing validate.
74
+
75
+ 4. **Refresh internal links when present:**
76
+
77
+ \`\`\`sh
78
+ contentbit links <content glob> --fix
79
+ \`\`\`
80
+
81
+ \`--fix\` only rewrites \`linksTo\` values that point at known aliases. It
82
+ does not invent links, remove aliases, or write backlinks. Re-run validate
83
+ after it changes files.
59
84
 
60
85
  ## Failure modes
61
86
 
@@ -77,6 +102,8 @@ version: ${TEMPLATE_VERSION}
77
102
 
78
103
  \`contentbit stats\` analyzes documents and prints JSON to stdout. It is a read
79
104
  tool: it always exits 0, even when documents have validation errors.
105
+ \`contentbit links\` builds the frontmatter-authored internal-link graph and
106
+ prints link diagnostics.
80
107
 
81
108
  ## Gather
82
109
 
@@ -85,23 +112,31 @@ content glob and \`--registry\` flag, then:
85
112
 
86
113
  \`\`\`sh
87
114
  contentbit stats "content/**/*.md" [--registry <path>]
115
+ contentbit links "content/**/*.md"
88
116
  \`\`\`
89
117
 
90
118
  One matched file prints a single stats object; multiple files print an array.
91
119
  Each entry includes the file path, frontmatter data, a heading \`outline\` with
92
120
  per-section word counts, \`blocks.byName\` usage counts, \`links.domains\`, and
93
121
  a \`validation\` summary (\`errors\`/\`warnings\`).
122
+ \`contentbit links\` also writes \`.contentbit/link-index.json\`, whose pages
123
+ contain \`slug\`, resolved \`linksTo\`, derived \`linkedFrom\`, \`aliases\`, and
124
+ \`keywords\`.
94
125
 
95
126
  ## Interpret
96
127
 
97
128
  Prioritize findings in this order:
98
129
 
99
130
  1. **Validation errors and warnings** — broken content ships broken pages.
100
- 2. **Thin documents** — outline sections with very low word counts.
101
- 3. **Block-less documents** — \`blocks.byName\` empty where sibling documents
131
+ 2. **Internal-link errors** — unresolved links, duplicate slugs, and alias
132
+ conflicts from \`contentbit links\`.
133
+ 3. **Orphans and self-links** — link warnings that point to isolated or noisy
134
+ pages.
135
+ 4. **Thin documents** — outline sections with very low word counts.
136
+ 5. **Block-less documents** — \`blocks.byName\` empty where sibling documents
102
137
  use blocks; structure (steps, callouts, comparisons, faq) may be missing.
103
- 4. **Missing or inconsistent frontmatter** compared to sibling documents.
104
- 5. **Structural imbalance** — skipped heading levels, single-section walls of text.
138
+ 6. **Missing or inconsistent frontmatter** compared to sibling documents.
139
+ 7. **Structural imbalance** — skipped heading levels, single-section walls of text.
105
140
 
106
141
  ## Report
107
142
 
@@ -117,14 +152,22 @@ This project validates Markdown content with contentbit. Documents are plain
117
152
  Markdown plus directive blocks (\`:::name{props} ... :::\`), each with a schema.
118
153
  The \`content:check\` script in package.json holds the canonical validate
119
154
  command — the content glob and the \`--registry\` flag — reuse its arguments.
155
+ If the project has a \`content:links\` script, use it to build the internal-link
156
+ index; otherwise run \`contentbit links <content glob>\`.
120
157
 
121
158
  When writing or editing content:
122
159
 
123
160
  1. Fetch the live authoring guide first — never guess block syntax:
124
161
  \`contentbit instructions --audience llm [--registry <path>]\`
125
162
  2. Write plain Markdown; use blocks where the guide's use-when guidance fits.
126
- 3. Validate until clean (exit 0): \`contentbit validate <file> [--registry <path>]\`.
163
+ 3. If sibling documents use \`slug\` / \`linksTo\`, read
164
+ \`.contentbit/link-index.json\` from \`contentbit links <content glob>\` and
165
+ author frontmatter links with existing slugs. When creating a linked page,
166
+ include \`keywords.primary\` and \`keywords.secondary\` with search-intent
167
+ phrases future agents can use to choose related pages.
168
+ 4. Validate until clean (exit 0): \`contentbit validate <file> [--registry <path>]\`.
127
169
  Diagnostics print as \`file:line:col severity CODE message\` with fix hints.
170
+ For link frontmatter, validate the full content glob so cross-file checks run.
128
171
 
129
172
  When auditing content health:
130
173
 
@@ -132,6 +175,9 @@ When auditing content health:
132
175
  and always exits 0: outline word counts, block usage, link domains, and
133
176
  validation error/warning counts. Flag validation issues, thin documents, and
134
177
  block-less pages first.
178
+ - \`contentbit links "content/**/*.md" [--fix]\` builds
179
+ \`.contentbit/link-index.json\`, reports dangling links/orphans, and rewrites
180
+ alias references in \`linksTo\` when \`--fix\` is used.
135
181
 
136
182
  If \`contentbit\` is unavailable, suggest \`npx contentbit@latest init\` instead
137
183
  of inventing block syntax.
@@ -1 +1 @@
1
- {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAgYnC,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAyNzE"}
1
+ {"version":3,"file":"init.d.ts","sourceRoot":"","sources":["../../src/commands/init.ts"],"names":[],"mappings":"AAMA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAianC,wBAAsB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAqOzE"}
@@ -78,7 +78,18 @@ export const blockComponents: Record<string, BlockComponent> = {
78
78
  }
79
79
  `;
80
80
  }
81
- const EXAMPLE_CONTENT = `# Hello, Content Blocks
81
+ const EXAMPLE_CONTENT = `---
82
+ slug: hello-content-blocks
83
+ linksTo:
84
+ - related-contentbit-workflows
85
+ aliases:
86
+ - getting-started-contentbit
87
+ keywords:
88
+ primary: validated Markdown blocks
89
+ secondary: [content workflow, agent writing]
90
+ ---
91
+
92
+ # Hello, Content Blocks
82
93
 
83
94
  Regular Markdown works everywhere. Blocks add validated structure:
84
95
 
@@ -100,6 +111,27 @@ The Analytical Engine weaves algebraic patterns just as the Jacquard loom
100
111
  weaves flowers and leaves.
101
112
  :::
102
113
  `;
114
+ const RELATED_CONTENT = `---
115
+ slug: related-contentbit-workflows
116
+ linksTo:
117
+ - hello-content-blocks
118
+ keywords:
119
+ primary: contentbit workflow
120
+ secondary: [validation loop, internal links]
121
+ ---
122
+
123
+ # Related contentbit workflows
124
+
125
+ This supporting page exists to show internal links in frontmatter. The link
126
+ graph is authored once with \`slug\` and \`linksTo\`, then contentbit derives
127
+ \`linkedFrom\` in \`.contentbit/link-index.json\`.
128
+
129
+ :::callout{type="note"}
130
+ Run \`contentbit links "content/**/*.md" --fix\` after renaming a page. Alias
131
+ references in \`linksTo\` are rewritten to the current slug, while \`aliases\`
132
+ stays as the rename record.
133
+ :::
134
+ `;
103
135
  /** The wrapper component: styled pack or headless, with or without a Markdown lib. */
104
136
  function reactComponent(styled, mdWired, blocksImport) {
105
137
  const mdImport = mdWired ? "import ReactMarkdown from 'react-markdown'\n" : '';
@@ -117,7 +149,7 @@ import { ContentRenderer } from '@/components/content-blocks/content-renderer'`
117
149
  return `'use client'
118
150
 
119
151
  import { genericBlocks } from '@contentbit/blocks'
120
- import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
152
+ import { createBlockRegistry, parseDocument, stripFrontmatter, validateDocument } from '@contentbit/core'
121
153
  ${reactImport}${mdImport}${rendererImport}
122
154
  // Everything block-related lives in the blocks/ folder: definitions in
123
155
  // registry.ts (shared with the validate CLI), components in components.tsx.
@@ -127,7 +159,7 @@ import { blockComponents } from '${blocksImport}/components'
127
159
  const registry = createBlockRegistry().use(genericBlocks()).use(customBlocks)
128
160
 
129
161
  export function Content({ source }: { source: string }) {
130
- const result = validateDocument(parseDocument(source), registry)
162
+ const result = validateDocument(parseDocument(stripFrontmatter(source)), registry)
131
163
  return (
132
164
  <${renderer}
133
165
  document={result.document}
@@ -151,14 +183,14 @@ const renderMarkdown = (md) => mdIt.render(md)`
151
183
  const renderMarkdown = undefined`;
152
184
  return `// Render content/example.md to example.html. Run: node scripts/render-example.mjs
153
185
  import { genericBlocks } from '@contentbit/blocks'
154
- import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
186
+ import { createBlockRegistry, parseDocument, stripFrontmatter, validateDocument } from '@contentbit/core'
155
187
  import { renderToHtml } from '@contentbit/html'
156
188
  import { readFile, writeFile } from 'node:fs/promises'
157
189
  ${wiring}
158
190
 
159
191
  const source = await readFile('content/example.md', 'utf8')
160
192
  const registry = createBlockRegistry().use(genericBlocks())
161
- const result = validateDocument(parseDocument(source), registry)
193
+ const result = validateDocument(parseDocument(stripFrontmatter(source)), registry)
162
194
  const html = renderToHtml(result.document, { renderMarkdown })
163
195
  await writeFile('example.html', html, 'utf8')
164
196
  console.log('wrote example.html')
@@ -464,6 +496,7 @@ export async function initCommand(args, io) {
464
496
  const files = [
465
497
  ['blocks/registry.ts', REGISTRY_TEMPLATE],
466
498
  ['content/example.md', EXAMPLE_CONTENT],
499
+ ['content/related.md', RELATED_CONTENT],
467
500
  ];
468
501
  const layout = detectFramework(cwd, { ...pkg.dependencies, ...pkg.devDependencies });
469
502
  // shadcn project? Pull the styled component pack from the contentbit registry.
@@ -520,15 +553,21 @@ export async function initCommand(args, io) {
520
553
  const result = await scaffold(join(cwd, rel), content);
521
554
  io.stdout(`${result}: ${rel}`);
522
555
  }
523
- // Wire the validate script.
556
+ // Wire content scripts.
524
557
  const fresh = JSON.parse(await readFile(pkgPath, 'utf8'));
525
558
  fresh.scripts ??= {};
526
559
  if (!fresh.scripts['content:check']) {
527
560
  fresh.scripts['content:check'] =
528
561
  'contentbit validate "content/**/*.md" --registry ./blocks/registry.ts';
529
- await writeFile(pkgPath, `${JSON.stringify(fresh, null, 2)}\n`, 'utf8');
530
562
  io.stdout('added script: content:check');
531
563
  }
564
+ if (!fresh.scripts['content:links']) {
565
+ fresh.scripts['content:links'] = 'contentbit links "content/**/*.md"';
566
+ io.stdout('added script: content:links');
567
+ }
568
+ if (!pkg.scripts?.['content:check'] || !pkg.scripts?.['content:links']) {
569
+ await writeFile(pkgPath, `${JSON.stringify(fresh, null, 2)}\n`, 'utf8');
570
+ }
532
571
  // Generate the LLM authoring guide from the registry, ready to paste into a prompt.
533
572
  let registry;
534
573
  try {
@@ -551,6 +590,7 @@ export async function initCommand(args, io) {
551
590
  io.stdout('');
552
591
  io.stdout('Done. Next steps:');
553
592
  io.stdout(` 1. Validate the starter content: ${detectPackageManager(cwd)} run content:check`);
593
+ io.stdout(` Build the link index: ${detectPackageManager(cwd)} run content:links`);
554
594
  if (target === 'react') {
555
595
  if (!values['no-page'] && layout.pagePath) {
556
596
  io.stdout(' 2. Start the dev server and open /example to see the article rendered.');
@@ -0,0 +1,3 @@
1
+ import type { Io } from '../run.js';
2
+ export declare function linksCommand(args: string[], io: Io): Promise<number>;
3
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/commands/links.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAuF1E"}
@@ -0,0 +1,97 @@
1
+ import { aliasReplacementsForPage, buildLinkIndex, extractFrontmatter, formatDiagnostic, serializeLinkIndex, validateLinks, } from '@contentbit/core';
2
+ import { mkdir, readFile } from 'node:fs/promises';
3
+ import { dirname, join } from 'node:path';
4
+ import { parseArgs } from 'node:util';
5
+ import { glob } from 'tinyglobby';
6
+ import { linkResolverOptions } from '../link-options.js';
7
+ import { collectLinkInputs } from '../links-io.js';
8
+ export async function linksCommand(args, io) {
9
+ const { values, positionals } = parseArgs({
10
+ args,
11
+ allowPositionals: true,
12
+ options: {
13
+ out: { type: 'string' },
14
+ fix: { type: 'boolean', default: false },
15
+ 'link-resolve': { type: 'string' },
16
+ 'locale-field': { type: 'string' },
17
+ 'slug-field': { type: 'string' },
18
+ 'key-field': { type: 'string' },
19
+ 'default-locale': { type: 'string' },
20
+ },
21
+ });
22
+ if (positionals.length === 0) {
23
+ io.stderr('links: provide at least one file or glob.');
24
+ return 2;
25
+ }
26
+ const files = (await glob(positionals, { absolute: true })).sort();
27
+ if (files.length === 0) {
28
+ io.stderr(`links: no files matched ${positionals.join(' ')}`);
29
+ return 2;
30
+ }
31
+ const inputs = await collectLinkInputs(files);
32
+ const linkOptions = linkResolverOptions(values);
33
+ let errors = 0;
34
+ let warnings = 0;
35
+ for (const { file, diagnostic } of validateLinks(inputs, linkOptions)) {
36
+ io.stderr(formatDiagnostic(diagnostic, file));
37
+ if (diagnostic.severity === 'error')
38
+ errors++;
39
+ else if (diagnostic.severity === 'warning')
40
+ warnings++;
41
+ }
42
+ const index = buildLinkIndex(inputs, linkOptions);
43
+ if (values.fix && errors > 0) {
44
+ io.stderr('links: --fix skipped because link errors must be resolved first.');
45
+ }
46
+ else if (values.fix && index.aliases.size > 0) {
47
+ for (const file of files) {
48
+ const source = await readFile(file, 'utf8');
49
+ const fm = extractFrontmatter(source);
50
+ if (!fm)
51
+ continue;
52
+ const lines = source.split('\n');
53
+ let changed = false;
54
+ // Only rewrite alias tokens inside the `linksTo:` block — never inside an
55
+ // `aliases:` list (which records the rename and must stay intact), and
56
+ // never in document body. Track whether we are within linksTo's scope:
57
+ // a top-level key resets it, dash-list / inline continuation keeps it.
58
+ let inLinksTo = false;
59
+ for (let i = 0; i < fm.lines.end && i < lines.length; i++) {
60
+ const line = lines[i];
61
+ const topKey = line.match(/^([A-Za-z0-9_.-]+):(.*)$/);
62
+ if (topKey)
63
+ inLinksTo = topKey[1] === 'linksTo';
64
+ if (!inLinksTo)
65
+ continue;
66
+ let next = line;
67
+ for (const [alias, current] of aliasReplacementsForPage(index, fm.data)) {
68
+ const re = new RegExp(`(^|[\\s\\[,'"-])${escapeRe(alias)}($|[\\s\\],'"])`, 'g');
69
+ next = next.replace(re, (_m, p1, p2) => `${p1}${current}${p2}`);
70
+ }
71
+ if (next !== line) {
72
+ lines[i] = next;
73
+ changed = true;
74
+ }
75
+ }
76
+ if (changed) {
77
+ await io.writeFile(file, lines.join('\n'));
78
+ io.stdout(`fixed alias references in ${file}`);
79
+ }
80
+ }
81
+ }
82
+ const outPath = values.out ?? join(process.cwd(), '.contentbit', 'link-index.json');
83
+ // The default target lives in a .contentbit/ dir that may not exist yet, and
84
+ // the shared Io.writeFile is a thin fs wrapper that won't create it.
85
+ await mkdir(dirname(outPath), { recursive: true });
86
+ await io.writeFile(outPath, JSON.stringify(serializeLinkIndex(index), null, 2) + '\n');
87
+ let edges = 0;
88
+ for (const p of index.pages.values())
89
+ edges += p.linksTo.length;
90
+ const orphans = [...index.pages.values()].filter((p) => p.linkedFrom.length === 0).length;
91
+ io.stdout(`${index.pages.size} page(s), ${edges} link(s), ${orphans} orphan(s): ${errors} errors, ${warnings} warnings`);
92
+ io.stdout(`index written to ${outPath}`);
93
+ return errors > 0 ? 1 : 0;
94
+ }
95
+ function escapeRe(s) {
96
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
97
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/commands/render.ts"],"names":[],"mappings":"AAWA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAInC,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B3E"}
1
+ {"version":3,"file":"render.d.ts","sourceRoot":"","sources":["../../src/commands/render.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAInC,wBAAsB,aAAa,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA6B3E"}
@@ -1,5 +1,5 @@
1
1
  import { genericMarkdownRenderers } from '@contentbit/blocks';
2
- import { formatDiagnostic, parseDocument, renderToMarkdown, validateDocument, } from '@contentbit/core';
2
+ import { formatDiagnostic, parseDocument, renderToMarkdown, stripFrontmatter, validateDocument, } from '@contentbit/core';
3
3
  import { renderToHtml } from '@contentbit/html';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { parseArgs } from 'node:util';
@@ -21,7 +21,7 @@ export async function renderCommand(args, io) {
21
21
  }
22
22
  const registry = await loadRegistry(values.registry);
23
23
  const source = await readFile(file, 'utf8');
24
- const result = validateDocument(parseDocument(source), registry);
24
+ const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
25
25
  if (!result.ok) {
26
26
  for (const d of result.diagnostics)
27
27
  io.stderr(formatDiagnostic(d, file));
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAUA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAInC,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAsC7E"}
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAKnC,wBAAsB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAwD7E"}
@@ -1,7 +1,8 @@
1
- import { formatDiagnostic, parseDocument, stripFrontmatter, validateDocument, } from '@contentbit/core';
1
+ import { extractFrontmatter, formatDiagnostic, parseDocument, stripFrontmatter, validateDocument, validateLinks, } from '@contentbit/core';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import { parseArgs } from 'node:util';
4
4
  import { glob } from 'tinyglobby';
5
+ import { linkResolverOptions } from '../link-options.js';
5
6
  import { loadRegistry } from '../load-registry.js';
6
7
  export async function validateCommand(args, io) {
7
8
  const { values, positionals } = parseArgs({
@@ -10,6 +11,11 @@ export async function validateCommand(args, io) {
10
11
  options: {
11
12
  registry: { type: 'string' },
12
13
  'strict-warnings': { type: 'boolean', default: false },
14
+ 'link-resolve': { type: 'string' },
15
+ 'locale-field': { type: 'string' },
16
+ 'slug-field': { type: 'string' },
17
+ 'key-field': { type: 'string' },
18
+ 'default-locale': { type: 'string' },
13
19
  },
14
20
  });
15
21
  if (positionals.length === 0) {
@@ -22,13 +28,16 @@ export async function validateCommand(args, io) {
22
28
  return 2;
23
29
  }
24
30
  const registry = await loadRegistry(values.registry);
31
+ const linkOptions = linkResolverOptions(values);
25
32
  let errors = 0;
26
33
  let warnings = 0;
34
+ const linkInputs = [];
27
35
  for (const file of files.sort()) {
28
36
  const source = await readFile(file, 'utf8');
29
37
  // Frontmatter is metadata, not content: blanked (positions preserved) so
30
38
  // block syntax inside YAML never produces diagnostics — matching what
31
39
  // frontmatter-aware consumers like Astro validate from entry bodies.
40
+ linkInputs.push({ path: file, data: extractFrontmatter(source)?.data ?? {} });
32
41
  const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
33
42
  for (const d of result.diagnostics) {
34
43
  io.stderr(formatDiagnostic(d, file));
@@ -38,6 +47,16 @@ export async function validateCommand(args, io) {
38
47
  warnings++;
39
48
  }
40
49
  }
50
+ // Cross-file internal-link checks, only when the project uses linking.
51
+ if (linkInputs.some((i) => 'slug' in i.data)) {
52
+ for (const { file, diagnostic } of validateLinks(linkInputs, linkOptions)) {
53
+ io.stderr(formatDiagnostic(diagnostic, file));
54
+ if (diagnostic.severity === 'error')
55
+ errors++;
56
+ else if (diagnostic.severity === 'warning')
57
+ warnings++;
58
+ }
59
+ }
41
60
  io.stdout(`${files.length} file(s): ${errors} errors, ${warnings} warnings`);
42
61
  if (errors > 0)
43
62
  return 1;
@@ -0,0 +1,10 @@
1
+ import type { LinkResolverOptions } from '@contentbit/core';
2
+ export interface LinkOptionValues {
3
+ 'link-resolve'?: string | boolean;
4
+ 'locale-field'?: string | boolean;
5
+ 'slug-field'?: string | boolean;
6
+ 'key-field'?: string | boolean;
7
+ 'default-locale'?: string | boolean;
8
+ }
9
+ export declare function linkResolverOptions(values: LinkOptionValues): LinkResolverOptions;
10
+ //# sourceMappingURL=link-options.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"link-options.d.ts","sourceRoot":"","sources":["../src/link-options.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kBAAkB,CAAA;AAE3D,MAAM,WAAW,gBAAgB;IAC/B,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACjC,cAAc,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IACjC,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC/B,WAAW,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;IAC9B,gBAAgB,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;CACpC;AAED,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,gBAAgB,GAAG,mBAAmB,CAgBjF"}
@@ -0,0 +1,31 @@
1
+ export function linkResolverOptions(values) {
2
+ const out = {};
3
+ const resolve = stringValue(values['link-resolve']);
4
+ if (resolve) {
5
+ if (!isResolveMode(resolve))
6
+ throw new Error(`invalid --link-resolve ${resolve}`);
7
+ out.resolve = resolve;
8
+ }
9
+ const localeField = stringValue(values['locale-field']);
10
+ const slugField = stringValue(values['slug-field']);
11
+ const keyField = stringValue(values['key-field']);
12
+ const defaultLocale = stringValue(values['default-locale']);
13
+ if (localeField)
14
+ out.localeField = localeField;
15
+ if (slugField)
16
+ out.slugField = slugField;
17
+ if (keyField)
18
+ out.keyField = keyField;
19
+ if (defaultLocale)
20
+ out.defaultLocale = defaultLocale;
21
+ return out;
22
+ }
23
+ function stringValue(value) {
24
+ return typeof value === 'string' && value.length > 0 ? value : undefined;
25
+ }
26
+ function isResolveMode(value) {
27
+ return (value === 'global-slug' ||
28
+ value === 'same-locale-slug' ||
29
+ value === 'same-locale-key' ||
30
+ value === 'prefer-same-locale-key-fallback-slug');
31
+ }
@@ -0,0 +1,3 @@
1
+ import { type LinkInput } from '@contentbit/core';
2
+ export declare function collectLinkInputs(files: string[]): Promise<LinkInput[]>;
3
+ //# sourceMappingURL=links-io.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links-io.d.ts","sourceRoot":"","sources":["../src/links-io.ts"],"names":[],"mappings":"AAAA,OAAO,EAAsB,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAMrE,wBAAsB,iBAAiB,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,SAAS,EAAE,CAAC,CAQ7E"}
@@ -0,0 +1,14 @@
1
+ import { extractFrontmatter } from '@contentbit/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ // Read each file's frontmatter (head only — bodies are never parsed) into the
4
+ // LinkInput shape the core link functions consume. Files with no frontmatter
5
+ // contribute an empty data object (a non-participating page).
6
+ export async function collectLinkInputs(files) {
7
+ const inputs = [];
8
+ for (const path of files) {
9
+ const source = await readFile(path, 'utf8');
10
+ const fm = extractFrontmatter(source);
11
+ inputs.push({ path, data: fm?.data ?? {} });
12
+ }
13
+ return inputs;
14
+ }
package/dist/run.d.ts CHANGED
@@ -3,6 +3,6 @@ export interface Io {
3
3
  stderr(line: string): void;
4
4
  writeFile(path: string, content: string): Promise<void>;
5
5
  }
6
- export declare const USAGE = "Usage: contentbit <init|validate|stats|render|instructions|docs|agents> [options]\n\n init [-t react|html|markdown|astro] [--md ...] [-y] [--no-install] [--no-page] [--no-agents]\n agents [--claude] [--no-agents-md]\n\n validate <globs...> [--registry <module.mjs>] [--strict-warnings]\n stats <globs...> [--registry <module.mjs>] [--no-validate]\n render <file> --target html|markdown [--registry <module.mjs>] [--out <file>]\n instructions [--audience llm|human] [--no-examples] [--registry <module.mjs>] [--out <file>]\n docs [--registry <module.mjs>] [--out <file>]";
6
+ export declare const USAGE = "Usage: contentbit <init|validate|stats|render|instructions|docs|agents|links> [options]\n\n init [-t react|html|markdown|astro] [--md ...] [-y] [--no-install] [--no-page] [--no-agents]\n agents [--claude] [--no-agents-md]\n\n validate <globs...> [--registry <module.mjs>] [--strict-warnings] [--link-resolve <mode>]\n stats <globs...> [--registry <module.mjs>] [--no-validate]\n render <file> --target html|markdown [--registry <module.mjs>] [--out <file>]\n instructions [--audience llm|human] [--no-examples] [--registry <module.mjs>] [--out <file>]\n docs [--registry <module.mjs>] [--out <file>]\n links <globs...> [--fix] [--out <file>] [--link-resolve <mode>]";
7
7
  export declare function run(argv: string[], io: Io): Promise<number>;
8
8
  //# sourceMappingURL=run.d.ts.map
package/dist/run.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,EAAE;IACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxD;AAED,eAAO,MAAM,KAAK,qkBAS8B,CAAA;AAchD,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAcjE"}
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,EAAE;IACjB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAA;IAC1B,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;CACxD;AAED,eAAO,MAAM,KAAK,sqBAUgD,CAAA;AAelE,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CAcjE"}