contentbit 0.1.1 → 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.
@@ -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));
@@ -0,0 +1,3 @@
1
+ import type { Io } from '../run.js';
2
+ export declare function statsCommand(args: string[], io: Io): Promise<number>;
3
+ //# sourceMappingURL=stats.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stats.d.ts","sourceRoot":"","sources":["../../src/commands/stats.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,EAAE,EAAE,MAAM,WAAW,CAAA;AAuBnC,wBAAsB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,EAAE,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA2B1E"}
@@ -0,0 +1,49 @@
1
+ import { analyzeDocument, parseDocument, stripFrontmatter, validateDocument, } from '@contentbit/core';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { parseArgs } from 'node:util';
4
+ import { glob } from 'tinyglobby';
5
+ import { loadRegistry } from '../load-registry.js';
6
+ async function fileStats(file, registry) {
7
+ const source = await readFile(file, 'utf8');
8
+ const stats = analyzeDocument(source, { path: file });
9
+ if (registry) {
10
+ const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
11
+ let errors = 0;
12
+ let warnings = 0;
13
+ for (const d of result.diagnostics) {
14
+ if (d.severity === 'error')
15
+ errors++;
16
+ else if (d.severity === 'warning')
17
+ warnings++;
18
+ }
19
+ stats.validation = { errors, warnings };
20
+ }
21
+ return stats;
22
+ }
23
+ export async function statsCommand(args, io) {
24
+ const { values, positionals } = parseArgs({
25
+ args,
26
+ allowPositionals: true,
27
+ options: {
28
+ registry: { type: 'string' },
29
+ 'no-validate': { type: 'boolean', default: false },
30
+ },
31
+ });
32
+ if (positionals.length === 0) {
33
+ io.stderr('stats: provide at least one file or glob.');
34
+ return 2;
35
+ }
36
+ const files = await glob(positionals, { absolute: true });
37
+ if (files.length === 0) {
38
+ io.stderr(`stats: no files matched ${positionals.join(' ')}`);
39
+ return 2;
40
+ }
41
+ const registry = values['no-validate'] ? null : await loadRegistry(values.registry);
42
+ const all = [];
43
+ for (const file of files.sort()) {
44
+ all.push(await fileStats(file, registry));
45
+ }
46
+ // A single file keeps the flat object shape; multiple files emit an array.
47
+ io.stdout(JSON.stringify(all.length === 1 ? all[0] : all, null, 2));
48
+ return 0;
49
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../../src/commands/validate.ts"],"names":[],"mappings":"AAKA,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,CAmC7E"}
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, 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,11 +28,17 @@ 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
- const result = validateDocument(parseDocument(source), registry);
37
+ // Frontmatter is metadata, not content: blanked (positions preserved) so
38
+ // block syntax inside YAML never produces diagnostics — matching what
39
+ // frontmatter-aware consumers like Astro validate from entry bodies.
40
+ linkInputs.push({ path: file, data: extractFrontmatter(source)?.data ?? {} });
41
+ const result = validateDocument(parseDocument(stripFrontmatter(source)), registry);
30
42
  for (const d of result.diagnostics) {
31
43
  io.stderr(formatDiagnostic(d, file));
32
44
  if (d.severity === 'error')
@@ -35,6 +47,16 @@ export async function validateCommand(args, io) {
35
47
  warnings++;
36
48
  }
37
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
+ }
38
60
  io.stdout(`${files.length} file(s): ${errors} errors, ${warnings} warnings`);
39
61
  if (errors > 0)
40
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|render|instructions|docs> [options]\n\n init [-t react|html|markdown] [--md ...] [-y] [--no-install] [--no-page]\n\n validate <globs...> [--registry <module.mjs>] [--strict-warnings]\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,gcAO8B,CAAA;AAYhD,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"}