@contentbit/core 0.1.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.
@@ -0,0 +1,87 @@
1
+ export interface OutlineEntry {
2
+ /** ATX heading level, 1-6. */
3
+ level: number;
4
+ text: string;
5
+ /** 1-based source line. */
6
+ line: number;
7
+ /** Prose words from this heading (inclusive) until the next heading of any level. */
8
+ words: number;
9
+ }
10
+ export interface BlockInstance {
11
+ name: string;
12
+ /** 1-based line of the opening fence. */
13
+ line: number;
14
+ /** 1 for top-level blocks, +1 per nesting level. */
15
+ depth: number;
16
+ }
17
+ export interface LinkItem {
18
+ url: string;
19
+ text: string;
20
+ line: number;
21
+ external: boolean;
22
+ }
23
+ export interface DocumentStats {
24
+ file: {
25
+ path: string | null;
26
+ bytes: number;
27
+ lines: number;
28
+ };
29
+ frontmatter: {
30
+ present: boolean;
31
+ keys: string[];
32
+ data: Record<string, unknown>;
33
+ lines: {
34
+ start: number;
35
+ end: number;
36
+ } | null;
37
+ };
38
+ length: {
39
+ /** Prose words only: frontmatter, code, and markup syntax are excluded. */
40
+ words: number;
41
+ /** Characters of the full source, frontmatter included. */
42
+ characters: number;
43
+ /** ceil(words / 200). */
44
+ readingMinutes: number;
45
+ /** Rough heuristic: ceil(characters / 4). */
46
+ approxTokens: number;
47
+ };
48
+ outline: OutlineEntry[];
49
+ blocks: {
50
+ total: number;
51
+ byName: Record<string, number>;
52
+ maxDepth: number;
53
+ instances: BlockInstance[];
54
+ };
55
+ links: {
56
+ total: number;
57
+ external: number;
58
+ internal: number;
59
+ domains: string[];
60
+ items: LinkItem[];
61
+ };
62
+ images: {
63
+ total: number;
64
+ missingAlt: number;
65
+ };
66
+ code: {
67
+ fences: number;
68
+ languages: string[];
69
+ inlineSpans: number;
70
+ };
71
+ structure: {
72
+ listItems: number;
73
+ tables: number;
74
+ blockquotes: number;
75
+ };
76
+ }
77
+ export interface AnalyzeOptions {
78
+ /** Reported back in `file.path`; analyze never touches the filesystem. */
79
+ path?: string;
80
+ }
81
+ /**
82
+ * Scan a document and return structured stats: prose length, heading outline,
83
+ * block census, links, images, code, and structure counts. Purely syntactic
84
+ * and deterministic — no registry, no validation, no filesystem access.
85
+ */
86
+ export declare function analyzeDocument(source: string, options?: AnalyzeOptions): DocumentStats;
87
+ //# sourceMappingURL=analyze.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"analyze.d.ts","sourceRoot":"","sources":["../src/analyze.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,YAAY;IAC3B,8BAA8B;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,2BAA2B;IAC3B,IAAI,EAAE,MAAM,CAAA;IACZ,qFAAqF;IACrF,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,MAAM,CAAA;IACZ,yCAAyC;IACzC,IAAI,EAAE,MAAM,CAAA;IACZ,oDAAoD;IACpD,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,QAAQ;IACvB,GAAG,EAAE,MAAM,CAAA;IACX,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,OAAO,CAAA;CAClB;AAED,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE;QAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,KAAK,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,CAAA;IAC3D,WAAW,EAAE;QACX,OAAO,EAAE,OAAO,CAAA;QAChB,IAAI,EAAE,MAAM,EAAE,CAAA;QACd,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;QAC7B,KAAK,EAAE;YAAE,KAAK,EAAE,MAAM,CAAC;YAAC,GAAG,EAAE,MAAM,CAAA;SAAE,GAAG,IAAI,CAAA;KAC7C,CAAA;IACD,MAAM,EAAE;QACN,2EAA2E;QAC3E,KAAK,EAAE,MAAM,CAAA;QACb,2DAA2D;QAC3D,UAAU,EAAE,MAAM,CAAA;QAClB,yBAAyB;QACzB,cAAc,EAAE,MAAM,CAAA;QACtB,6CAA6C;QAC7C,YAAY,EAAE,MAAM,CAAA;KACrB,CAAA;IACD,OAAO,EAAE,YAAY,EAAE,CAAA;IACvB,MAAM,EAAE;QACN,KAAK,EAAE,MAAM,CAAA;QACb,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;QAC9B,QAAQ,EAAE,MAAM,CAAA;QAChB,SAAS,EAAE,aAAa,EAAE,CAAA;KAC3B,CAAA;IACD,KAAK,EAAE;QACL,KAAK,EAAE,MAAM,CAAA;QACb,QAAQ,EAAE,MAAM,CAAA;QAChB,QAAQ,EAAE,MAAM,CAAA;QAChB,OAAO,EAAE,MAAM,EAAE,CAAA;QACjB,KAAK,EAAE,QAAQ,EAAE,CAAA;KAClB,CAAA;IACD,MAAM,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,MAAM,CAAA;KAAE,CAAA;IAC7C,IAAI,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,SAAS,EAAE,MAAM,EAAE,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAA;IAClE,SAAS,EAAE;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAC;QAAC,WAAW,EAAE,MAAM,CAAA;KAAE,CAAA;CACtE;AAED,MAAM,WAAW,cAAc;IAC7B,0EAA0E;IAC1E,IAAI,CAAC,EAAE,MAAM,CAAA;CACd;AAsBD;;;;GAIG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,OAAO,GAAE,cAAmB,GAAG,aAAa,CA4L3F"}
@@ -0,0 +1,204 @@
1
+ import { extractFrontmatter, stripFrontmatter } from './frontmatter.js';
2
+ import { parseDocument } from './parser.js';
3
+ /** UTF-8 byte length without Node Buffer or TextEncoder (core is environment-agnostic). */
4
+ function utf8Length(source) {
5
+ let bytes = 0;
6
+ for (const ch of source) {
7
+ const cp = ch.codePointAt(0);
8
+ bytes += cp <= 0x7f ? 1 : cp <= 0x7ff ? 2 : cp <= 0xffff ? 3 : 4;
9
+ }
10
+ return bytes;
11
+ }
12
+ const CODE_FENCE_RE = /^(`{3,}|~{3,})(.*)$/;
13
+ const HEADING_RE = /^(#{1,6})\s+(.*)$/;
14
+ const LIST_ITEM_RE = /^\s*(?:[-*+]|\d+[.)])\s+/;
15
+ const TABLE_SEPARATOR_RE = /^\|?[\s:|-]+$/;
16
+ const CODE_SPAN_RE = /`[^`]+`/g;
17
+ const IMAGE_RE = /!\[([^\]]*)\]\(([^)]*)\)/g;
18
+ const LINK_RE = /\[([^\]]+)\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g;
19
+ const AUTOLINK_RE = /<(https?:\/\/[^>\s]+)>/g;
20
+ const EXTERNAL_URL_RE = /^(?:https?:)?\/\//i;
21
+ /**
22
+ * Scan a document and return structured stats: prose length, heading outline,
23
+ * block census, links, images, code, and structure counts. Purely syntactic
24
+ * and deterministic — no registry, no validation, no filesystem access.
25
+ */
26
+ export function analyzeDocument(source, options = {}) {
27
+ const frontmatter = extractFrontmatter(source);
28
+ const { document } = parseDocument(stripFrontmatter(source));
29
+ const outline = [];
30
+ const blocks = { total: 0, byName: {}, maxDepth: 0, instances: [] };
31
+ const links = {
32
+ total: 0,
33
+ external: 0,
34
+ internal: 0,
35
+ domains: [],
36
+ items: [],
37
+ };
38
+ const images = { total: 0, missingAlt: 0 };
39
+ const code = { fences: 0, languages: [], inlineSpans: 0 };
40
+ const structure = { listItems: 0, tables: 0, blockquotes: 0 };
41
+ const languages = new Set();
42
+ const domains = new Set();
43
+ let words = 0;
44
+ function addWords(count) {
45
+ words += count;
46
+ const section = outline[outline.length - 1];
47
+ if (section)
48
+ section.words += count;
49
+ }
50
+ function recordLink(url, text, line) {
51
+ const external = EXTERNAL_URL_RE.test(url);
52
+ links.total++;
53
+ if (external) {
54
+ links.external++;
55
+ const host = url.match(/^(?:https?:)?\/\/(?:[^/?#@]*@)?([^/?#:]+)/i);
56
+ if (host)
57
+ domains.add(host[1].toLowerCase());
58
+ }
59
+ else {
60
+ links.internal++;
61
+ }
62
+ links.items.push({ url, text, line, external });
63
+ }
64
+ /** Replace inline markup with its prose equivalent, recording links/images/spans. */
65
+ function inlineToProse(text, line) {
66
+ return text
67
+ .replace(CODE_SPAN_RE, () => {
68
+ code.inlineSpans++;
69
+ return ' ';
70
+ })
71
+ .replace(IMAGE_RE, (_, alt) => {
72
+ images.total++;
73
+ if (alt.trim() === '')
74
+ images.missingAlt++;
75
+ return ' ';
76
+ })
77
+ .replace(LINK_RE, (_, label, url) => {
78
+ recordLink(url, label, line);
79
+ return label;
80
+ })
81
+ .replace(AUTOLINK_RE, (_, url) => {
82
+ recordLink(url, url, line);
83
+ return ' ';
84
+ });
85
+ }
86
+ function countWords(text) {
87
+ return text.split(/\s+/).filter((token) => /[\p{L}\p{N}]/u.test(token)).length;
88
+ }
89
+ function scanMarkdown(value, startLine) {
90
+ let fence = null;
91
+ let inBlockquote = false;
92
+ let prevLineHasPipe = false;
93
+ const lines = value.split('\n');
94
+ for (let i = 0; i < lines.length; i++) {
95
+ const line = lines[i];
96
+ const lineNo = startLine + i;
97
+ const trimmed = line.trim();
98
+ const fenceMatch = trimmed.match(CODE_FENCE_RE);
99
+ if (fence !== null) {
100
+ if (fenceMatch && fenceMatch[1][0] === fence[0] && fenceMatch[1].length >= fence.length) {
101
+ fence = null;
102
+ }
103
+ continue;
104
+ }
105
+ if (fenceMatch) {
106
+ fence = fenceMatch[1];
107
+ code.fences++;
108
+ const lang = fenceMatch[2].trim().split(/\s+/)[0];
109
+ if (lang)
110
+ languages.add(lang);
111
+ inBlockquote = false;
112
+ prevLineHasPipe = false;
113
+ continue;
114
+ }
115
+ if (trimmed === '') {
116
+ inBlockquote = false;
117
+ prevLineHasPipe = false;
118
+ continue;
119
+ }
120
+ const heading = trimmed.match(HEADING_RE);
121
+ if (heading) {
122
+ const raw = heading[2].replace(/\s+#+\s*$/, '');
123
+ const text = inlineToProse(raw, lineNo).replace(/\s+/g, ' ').trim();
124
+ outline.push({ level: heading[1].length, text, line: lineNo, words: 0 });
125
+ addWords(countWords(text));
126
+ inBlockquote = false;
127
+ prevLineHasPipe = false;
128
+ continue;
129
+ }
130
+ let content = trimmed;
131
+ if (content.startsWith('>')) {
132
+ if (!inBlockquote) {
133
+ structure.blockquotes++;
134
+ inBlockquote = true;
135
+ }
136
+ content = content.replace(/^(>\s*)+/, '');
137
+ }
138
+ else {
139
+ inBlockquote = false;
140
+ }
141
+ if (LIST_ITEM_RE.test(content)) {
142
+ structure.listItems++;
143
+ content = content.replace(LIST_ITEM_RE, '');
144
+ }
145
+ if (content.includes('|')) {
146
+ if (TABLE_SEPARATOR_RE.test(content) && content.includes('-') && prevLineHasPipe) {
147
+ structure.tables++;
148
+ }
149
+ prevLineHasPipe = true;
150
+ content = inlineToProse(content, lineNo).replaceAll('|', ' ');
151
+ }
152
+ else {
153
+ prevLineHasPipe = false;
154
+ content = inlineToProse(content, lineNo);
155
+ }
156
+ addWords(countWords(content));
157
+ }
158
+ }
159
+ function walk(nodes, depth) {
160
+ for (const node of nodes) {
161
+ if (node.type === 'block') {
162
+ blocks.total++;
163
+ blocks.byName[node.name] = (blocks.byName[node.name] ?? 0) + 1;
164
+ blocks.maxDepth = Math.max(blocks.maxDepth, depth);
165
+ blocks.instances.push({ name: node.name, line: node.openPosition.start.line, depth });
166
+ walk(node.children, depth + 1);
167
+ }
168
+ else {
169
+ scanMarkdown(node.value, node.position.start.line);
170
+ }
171
+ }
172
+ }
173
+ walk(document.children, 1);
174
+ code.languages = [...languages];
175
+ links.domains = [...domains].sort();
176
+ const sourceLines = source === '' ? 0 : source.split('\n').length - (source.endsWith('\n') ? 1 : 0);
177
+ return {
178
+ file: {
179
+ path: options.path ?? null,
180
+ bytes: utf8Length(source),
181
+ lines: sourceLines,
182
+ },
183
+ frontmatter: frontmatter
184
+ ? {
185
+ present: true,
186
+ keys: frontmatter.keys,
187
+ data: frontmatter.data,
188
+ lines: frontmatter.lines,
189
+ }
190
+ : { present: false, keys: [], data: {}, lines: null },
191
+ length: {
192
+ words,
193
+ characters: source.length,
194
+ readingMinutes: Math.ceil(words / 200),
195
+ approxTokens: Math.ceil(source.length / 4),
196
+ },
197
+ outline,
198
+ blocks,
199
+ links,
200
+ images,
201
+ code,
202
+ structure,
203
+ };
204
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../src/authoring.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAEpD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,KAAK,GAAG,OAAO,CAAA;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAWD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,EAChC,IAAI,GAAE,qBAA0B,GAC/B,MAAM,CA0BR"}
1
+ {"version":3,"file":"authoring.d.ts","sourceRoot":"","sources":["../src/authoring.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,eAAe,CAAA;AAkEpD,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,KAAK,GAAG,OAAO,CAAA;IAC1B,eAAe,CAAC,EAAE,OAAO,CAAA;IACzB,iBAAiB,CAAC,EAAE,OAAO,CAAA;CAC5B;AAWD,wBAAgB,sBAAsB,CACpC,IAAI,EAAE,eAAe,CAAC,OAAO,CAAC,EAAE,EAChC,IAAI,GAAE,qBAA0B,GAC/B,MAAM,CA8BR"}
package/dist/authoring.js CHANGED
@@ -1,3 +1,50 @@
1
+ function typeLabel(def) {
2
+ switch (def.type) {
3
+ case 'enum':
4
+ return `one of ${Object.values(def.entries ?? {}).join('|')}`;
5
+ case 'literal':
6
+ return `one of ${(def.values ?? []).map(String).join('|')}`;
7
+ case 'union':
8
+ return `one of ${(def.options ?? [])
9
+ .map((o) => typeLabel(o.def).replace(/^one of /, ''))
10
+ .join('|')}`;
11
+ default:
12
+ return def.type;
13
+ }
14
+ }
15
+ /** One `- name: type (required|optional[, default: x]) [— description]` line per prop. */
16
+ function describeProps(schema) {
17
+ const root = schema.def;
18
+ if (root.type !== 'object' || !root.shape)
19
+ return [];
20
+ const lines = [];
21
+ for (const [name, field] of Object.entries(root.shape)) {
22
+ // Unwrap optional/default/nullable wrappers; .describe() may sit on any layer.
23
+ let current = field;
24
+ let optional = false;
25
+ let defaultValue;
26
+ let description = current.description;
27
+ while (['optional', 'default', 'nullable'].includes(current.def.type)) {
28
+ optional = true;
29
+ if (current.def.type === 'default') {
30
+ const dv = current.def.defaultValue;
31
+ defaultValue = typeof dv === 'function' ? dv() : dv;
32
+ }
33
+ if (!current.def.innerType)
34
+ break;
35
+ current = current.def.innerType;
36
+ description ??= current.description;
37
+ }
38
+ const presence = defaultValue !== undefined
39
+ ? `(optional, default: ${String(defaultValue)})`
40
+ : optional
41
+ ? '(optional)'
42
+ : '(required)';
43
+ const suffix = description ? ` — ${description}` : '';
44
+ lines.push(`- ${name}: ${typeLabel(current.def)} ${presence}${suffix}`);
45
+ }
46
+ return lines;
47
+ }
1
48
  const LLM_PREAMBLE = `# Content block authoring rules
2
49
 
3
50
  - write regular Markdown by default; use blocks only when they improve scanning or structure
@@ -17,6 +64,11 @@ export function generateAuthoringGuide(defs, opts = {}) {
17
64
  lines.push(def.childOnly
18
65
  ? `${def.description} (child block — only inside a parent that allows it)`
19
66
  : def.description);
67
+ if (def.props) {
68
+ const props = describeProps(def.props);
69
+ if (props.length > 0)
70
+ lines.push('', 'Props:', ...props);
71
+ }
20
72
  lines.push('', `Content: ${def.content.describe()}`);
21
73
  if (def.authoring.useWhen.length > 0) {
22
74
  lines.push('', 'Use when:', ...def.authoring.useWhen.map((u) => `- ${u}`));
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Blank out a leading YAML frontmatter block so block syntax inside it is
3
+ * never parsed as content. Every line is replaced by an empty line (the
4
+ * newlines stay), so diagnostic positions in the remaining document are
5
+ * unchanged. Sources without frontmatter are returned as-is.
6
+ */
7
+ export declare function stripFrontmatter(source: string): string;
8
+ export interface Frontmatter {
9
+ /** Raw YAML between the fences (fences excluded). */
10
+ raw: string;
11
+ /** Best-effort parsed data; values the subset parser can't handle stay raw strings. */
12
+ data: Record<string, unknown>;
13
+ /** Top-level keys in document order. */
14
+ keys: string[];
15
+ /** 1-based fence lines, inclusive. */
16
+ lines: {
17
+ start: number;
18
+ end: number;
19
+ };
20
+ }
21
+ /**
22
+ * Extract and parse a leading YAML frontmatter block. Parsing covers the flat
23
+ * subset real content frontmatter uses: `key: value` scalars (quoted/plain
24
+ * strings, numbers, booleans, null), inline arrays, dash lists, and block
25
+ * scalars. Nested mappings and anything else fall back to the raw text of the
26
+ * value, so no input ever throws. Returns null when there is no frontmatter.
27
+ */
28
+ export declare function extractFrontmatter(source: string): Frontmatter | null;
29
+ //# sourceMappingURL=frontmatter.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"frontmatter.d.ts","sourceRoot":"","sources":["../src/frontmatter.ts"],"names":[],"mappings":"AAMA;;;;;GAKG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,CAIvD;AAED,MAAM,WAAW,WAAW;IAC1B,qDAAqD;IACrD,GAAG,EAAE,MAAM,CAAA;IACX,uFAAuF;IACvF,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC7B,wCAAwC;IACxC,IAAI,EAAE,MAAM,EAAE,CAAA;IACd,sCAAsC;IACtC,KAAK,EAAE;QAAE,KAAK,EAAE,MAAM,CAAC;QAAC,GAAG,EAAE,MAAM,CAAA;KAAE,CAAA;CACtC;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAOrE"}
@@ -0,0 +1,202 @@
1
+ // A YAML frontmatter block at the very start of the file: an opening ---
2
+ // fence, an optional body (empty frontmatter is legal), and a closing ---
3
+ // fence. Both fences tolerate trailing spaces/tabs, which editors and CMS
4
+ // exports routinely leave behind.
5
+ const FM_RE = /^---[ \t]*\r?\n(?:[\s\S]*?\r?\n)?---[ \t]*(?:\r?\n|$)/;
6
+ /**
7
+ * Blank out a leading YAML frontmatter block so block syntax inside it is
8
+ * never parsed as content. Every line is replaced by an empty line (the
9
+ * newlines stay), so diagnostic positions in the remaining document are
10
+ * unchanged. Sources without frontmatter are returned as-is.
11
+ */
12
+ export function stripFrontmatter(source) {
13
+ const match = FM_RE.exec(source);
14
+ if (!match)
15
+ return source;
16
+ return match[0].replace(/[^\n]+/g, '') + source.slice(match[0].length);
17
+ }
18
+ /**
19
+ * Extract and parse a leading YAML frontmatter block. Parsing covers the flat
20
+ * subset real content frontmatter uses: `key: value` scalars (quoted/plain
21
+ * strings, numbers, booleans, null), inline arrays, dash lists, and block
22
+ * scalars. Nested mappings and anything else fall back to the raw text of the
23
+ * value, so no input ever throws. Returns null when there is no frontmatter.
24
+ */
25
+ export function extractFrontmatter(source) {
26
+ const match = source.match(FM_RE);
27
+ if (!match)
28
+ return null;
29
+ const blockLines = match[0].replace(/\r?\n$/, '').split(/\r?\n/);
30
+ const inner = blockLines.slice(1, -1);
31
+ const { data, keys } = parseYamlSubset(inner);
32
+ return { raw: inner.join('\n'), data, keys, lines: { start: 1, end: blockLines.length } };
33
+ }
34
+ const KEY_RE = /^([A-Za-z0-9_.-]+):(.*)$/;
35
+ function parseYamlSubset(lines) {
36
+ const data = {};
37
+ const keys = [];
38
+ let i = 0;
39
+ while (i < lines.length) {
40
+ const line = lines[i];
41
+ const trimmed = line.trim();
42
+ if (trimmed === '' || trimmed.startsWith('#') || /^[ \t]/.test(line)) {
43
+ i++;
44
+ continue;
45
+ }
46
+ const m = line.match(KEY_RE);
47
+ if (!m) {
48
+ i++;
49
+ continue;
50
+ }
51
+ const [, key, rawValue] = m;
52
+ const value = rawValue.trim();
53
+ i++;
54
+ const indented = [];
55
+ while (i < lines.length && /^[ \t]/.test(lines[i]) && lines[i].trim() !== '') {
56
+ indented.push(lines[i]);
57
+ i++;
58
+ }
59
+ keys.push(key);
60
+ data[key] = parseValue(value, indented);
61
+ }
62
+ return { data, keys };
63
+ }
64
+ function parseValue(value, indented) {
65
+ if (/^[|>][+-]?$/.test(value))
66
+ return dedent(indented).join('\n');
67
+ if (value === '') {
68
+ if (indented.length === 0)
69
+ return null;
70
+ const items = dedent(indented);
71
+ const list = parseDashList(items);
72
+ if (list)
73
+ return list;
74
+ const mapping = parseNestedMapping(items);
75
+ if (mapping)
76
+ return mapping;
77
+ return items.join('\n');
78
+ }
79
+ return parseScalar(value);
80
+ }
81
+ // A one-level mapping: every dedented line is `key: scalar` (or `key:` with an
82
+ // inline array value). Returns null if any line isn't a flat mapping entry —
83
+ // e.g. a deeper-indented line or a `key:` with no inline value, which would
84
+ // need its own nested block — so the caller keeps the raw-text fallback.
85
+ function parseNestedMapping(items) {
86
+ const out = {};
87
+ for (const line of items) {
88
+ if (/^[ \t]/.test(line))
89
+ return null;
90
+ const m = line.match(KEY_RE);
91
+ if (!m)
92
+ return null;
93
+ const [, key, rawValue] = m;
94
+ const v = rawValue.trim();
95
+ if (v === '')
96
+ return null;
97
+ out[key] = parseScalar(v);
98
+ }
99
+ return Object.keys(out).length > 0 ? out : null;
100
+ }
101
+ function parseDashList(items) {
102
+ const groups = [];
103
+ let current = null;
104
+ for (const line of items) {
105
+ if (line.startsWith('- ')) {
106
+ if (current)
107
+ groups.push(current);
108
+ current = [line.slice(2)];
109
+ }
110
+ else if (current && /^[ \t]/.test(line)) {
111
+ current.push(line);
112
+ }
113
+ else {
114
+ return null;
115
+ }
116
+ }
117
+ if (current)
118
+ groups.push(current);
119
+ return groups.map((group) => parseDashItem(group));
120
+ }
121
+ function parseDashItem(lines) {
122
+ const first = lines[0].trim();
123
+ if (lines.length === 1)
124
+ return parseNestedMapping([first]) ?? parseScalar(first);
125
+ const rest = dedent(lines.slice(1));
126
+ const mapping = parseNestedMapping([first, ...rest]);
127
+ return mapping ?? lines.join('\n');
128
+ }
129
+ function dedent(lines) {
130
+ const indent = Math.min(...lines.map((l) => l.match(/^[ \t]*/)[0].length));
131
+ return lines.map((l) => l.slice(indent));
132
+ }
133
+ function parseScalar(value) {
134
+ if (value === '' || value === 'null' || value === '~')
135
+ return null;
136
+ if (value === 'true')
137
+ return true;
138
+ if (value === 'false')
139
+ return false;
140
+ if (/^[+-]?\d+$/.test(value))
141
+ return Number.parseInt(value, 10);
142
+ if (/^[+-]?\d*\.\d+$/.test(value))
143
+ return Number.parseFloat(value);
144
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
145
+ try {
146
+ return JSON.parse(value);
147
+ }
148
+ catch {
149
+ return value.slice(1, -1);
150
+ }
151
+ }
152
+ if (value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
153
+ return value.slice(1, -1).replace(/''/g, "'");
154
+ }
155
+ if (value.startsWith('[') && value.endsWith(']')) {
156
+ const inner = value.slice(1, -1).trim();
157
+ if (inner === '')
158
+ return [];
159
+ return splitInlineItems(inner).map((item) => parseScalar(item.trim()));
160
+ }
161
+ if (value.startsWith('{') && value.endsWith('}')) {
162
+ const inner = value.slice(1, -1).trim();
163
+ if (inner === '')
164
+ return {};
165
+ const out = {};
166
+ for (const item of splitInlineItems(inner)) {
167
+ const m = item.trim().match(KEY_RE);
168
+ if (!m)
169
+ return value;
170
+ const [, key, rawValue] = m;
171
+ out[key] = parseScalar(rawValue.trim());
172
+ }
173
+ return out;
174
+ }
175
+ return value;
176
+ }
177
+ /** Split inline array items on top-level commas, respecting quotes. */
178
+ function splitInlineItems(inner) {
179
+ const items = [];
180
+ let current = '';
181
+ let quote = null;
182
+ for (const ch of inner) {
183
+ if (quote) {
184
+ current += ch;
185
+ if (ch === quote)
186
+ quote = null;
187
+ }
188
+ else if (ch === '"' || ch === "'") {
189
+ current += ch;
190
+ quote = ch;
191
+ }
192
+ else if (ch === ',') {
193
+ items.push(current);
194
+ current = '';
195
+ }
196
+ else {
197
+ current += ch;
198
+ }
199
+ }
200
+ items.push(current);
201
+ return items;
202
+ }
package/dist/index.d.ts CHANGED
@@ -3,6 +3,8 @@ export type { SourcePoint, SourceRange, Severity, Diagnostic } from './diagnosti
3
3
  export { formatDiagnostic } from './diagnostics.js';
4
4
  export type { DocumentNode, ContentNode, MarkdownNode, BlockNode } from './ast.js';
5
5
  export { parseDocument, type ParseResult } from './parser.js';
6
+ export { stripFrontmatter, extractFrontmatter, type Frontmatter } from './frontmatter.js';
7
+ export { analyzeDocument, type AnalyzeOptions, type DocumentStats, type OutlineEntry, type BlockInstance, type LinkItem, } from './analyze.js';
6
8
  export { parseProps } from './props.js';
7
9
  export { bodyLineRange } from './position.js';
8
10
  export { createBlockRegistry, defineBlock, BlockRegistry, type BlockDefinition, type ContentModel, type AuthoringMeta, type Report, } from './registry.js';
@@ -10,4 +12,5 @@ export { markdownBody, pipeRows, listItems, childBlocks, type MarkdownBodyData,
10
12
  export { validateDocument, isValidatedBlock, type ValidateOptions, type ValidationResult, type ValidatedBlockNode, } from './validate.js';
11
13
  export { generateAuthoringGuide, type AuthoringGuideOptions } from './authoring.js';
12
14
  export { renderToMarkdown, type MarkdownBlockRenderer, type MarkdownRenderContext, type RenderToMarkdownOptions, } from './render-markdown.js';
15
+ export { parseLinkFrontmatter, buildLinkIndex, validateLinks, serializeLinkIndex, aliasReplacementsForPage, type LinkResolveMode, type LinkResolverOptions, type LinkTarget, type LinkFrontmatter, type ParseLinkResult, type LinkReference, type IndexedPage, type LinkAliasEntry, type LinkIndex, type LinkInput, type LinkDiagnostic, type SerializedLinkIndex, } from './links.js';
13
16
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AACtF,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AACnD,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAClF,OAAO,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAC7C,OAAO,EACL,mBAAmB,EACnB,WAAW,EACX,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,MAAM,GACZ,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,eAAe,GACrB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,sBAAsB,EAAE,KAAK,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AACnF,OAAO,EACL,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,GAC7B,MAAM,sBAAsB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,OAAO,EAAE,MAAM,cAAc,CAAA;AACtC,YAAY,EAAE,WAAW,EAAE,WAAW,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AACtF,OAAO,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAA;AACnD,YAAY,EAAE,YAAY,EAAE,WAAW,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AAClF,OAAO,EAAE,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,aAAa,CAAA;AAC7D,OAAO,EAAE,gBAAgB,EAAE,kBAAkB,EAAE,KAAK,WAAW,EAAE,MAAM,kBAAkB,CAAA;AACzF,OAAO,EACL,eAAe,EACf,KAAK,cAAc,EACnB,KAAK,aAAa,EAClB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,QAAQ,GACd,MAAM,cAAc,CAAA;AACrB,OAAO,EAAE,UAAU,EAAE,MAAM,YAAY,CAAA;AACvC,OAAO,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAC7C,OAAO,EACL,mBAAmB,EACnB,WAAW,EACX,aAAa,EACb,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,MAAM,GACZ,MAAM,eAAe,CAAA;AACtB,OAAO,EACL,YAAY,EACZ,QAAQ,EACR,SAAS,EACT,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,YAAY,EACjB,KAAK,aAAa,EAClB,KAAK,QAAQ,EACb,KAAK,eAAe,GACrB,MAAM,qBAAqB,CAAA;AAC5B,OAAO,EACL,gBAAgB,EAChB,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,KAAK,kBAAkB,GACxB,MAAM,eAAe,CAAA;AACtB,OAAO,EAAE,sBAAsB,EAAE,KAAK,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AACnF,OAAO,EACL,gBAAgB,EAChB,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,EAC1B,KAAK,uBAAuB,GAC7B,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,aAAa,EACb,kBAAkB,EAClB,wBAAwB,EACxB,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,eAAe,EACpB,KAAK,aAAa,EAClB,KAAK,WAAW,EAChB,KAAK,cAAc,EACnB,KAAK,SAAS,EACd,KAAK,SAAS,EACd,KAAK,cAAc,EACnB,KAAK,mBAAmB,GACzB,MAAM,YAAY,CAAA"}
package/dist/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  export { VERSION } from './version.js';
2
2
  export { formatDiagnostic } from './diagnostics.js';
3
3
  export { parseDocument } from './parser.js';
4
+ export { stripFrontmatter, extractFrontmatter } from './frontmatter.js';
5
+ export { analyzeDocument, } from './analyze.js';
4
6
  export { parseProps } from './props.js';
5
7
  export { bodyLineRange } from './position.js';
6
8
  export { createBlockRegistry, defineBlock, BlockRegistry, } from './registry.js';
@@ -8,3 +10,4 @@ export { markdownBody, pipeRows, listItems, childBlocks, } from './content-model
8
10
  export { validateDocument, isValidatedBlock, } from './validate.js';
9
11
  export { generateAuthoringGuide } from './authoring.js';
10
12
  export { renderToMarkdown, } from './render-markdown.js';
13
+ export { parseLinkFrontmatter, buildLinkIndex, validateLinks, serializeLinkIndex, aliasReplacementsForPage, } from './links.js';
@@ -0,0 +1,96 @@
1
+ import { z } from 'zod';
2
+ import type { Diagnostic } from './diagnostics.js';
3
+ declare const LinkTarget: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
4
+ slug: z.ZodOptional<z.ZodString>;
5
+ key: z.ZodOptional<z.ZodString>;
6
+ locale: z.ZodOptional<z.ZodString>;
7
+ }, z.core.$strip>]>;
8
+ declare const LinkFrontmatter: z.ZodObject<{
9
+ slug: z.ZodString;
10
+ key: z.ZodOptional<z.ZodString>;
11
+ locale: z.ZodOptional<z.ZodString>;
12
+ title: z.ZodOptional<z.ZodString>;
13
+ linksTo: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
14
+ slug: z.ZodOptional<z.ZodString>;
15
+ key: z.ZodOptional<z.ZodString>;
16
+ locale: z.ZodOptional<z.ZodString>;
17
+ }, z.core.$strip>]>>>;
18
+ aliases: z.ZodOptional<z.ZodArray<z.ZodString>>;
19
+ keywords: z.ZodOptional<z.ZodObject<{
20
+ primary: z.ZodOptional<z.ZodString>;
21
+ secondary: z.ZodOptional<z.ZodArray<z.ZodString>>;
22
+ }, z.core.$strip>>;
23
+ }, z.core.$strip>;
24
+ export type LinkTarget = z.infer<typeof LinkTarget>;
25
+ export type LinkFrontmatter = z.infer<typeof LinkFrontmatter>;
26
+ export type LinkResolveMode = 'global-slug' | 'same-locale-slug' | 'same-locale-key' | 'prefer-same-locale-key-fallback-slug';
27
+ export interface LinkResolverOptions {
28
+ resolve?: LinkResolveMode;
29
+ localeField?: string;
30
+ slugField?: string;
31
+ keyField?: string;
32
+ defaultLocale?: string;
33
+ }
34
+ export type ParseLinkResult = {
35
+ ok: true;
36
+ value: LinkFrontmatter | null;
37
+ } | {
38
+ ok: false;
39
+ errors: string[];
40
+ };
41
+ export interface LinkReference {
42
+ target?: string;
43
+ locale?: string;
44
+ slug: string;
45
+ key?: string;
46
+ }
47
+ export interface IndexedPage {
48
+ slug: string;
49
+ key?: string;
50
+ locale?: string;
51
+ path: string;
52
+ title?: string;
53
+ keywords?: {
54
+ primary?: string;
55
+ secondary?: string[];
56
+ };
57
+ linksTo: string[];
58
+ linkedFrom: string[];
59
+ aliases: string[];
60
+ linkRefs: LinkReference[];
61
+ linkedFromRefs: LinkReference[];
62
+ }
63
+ export interface LinkAliasEntry {
64
+ alias: string;
65
+ locale?: string;
66
+ replacement: string;
67
+ page: IndexedPage;
68
+ }
69
+ export interface LinkIndex {
70
+ pages: Map<string, IndexedPage>;
71
+ aliases: Map<string, string>;
72
+ aliasEntries: LinkAliasEntry[];
73
+ options: Required<Pick<LinkResolverOptions, 'resolve'>> & Omit<LinkResolverOptions, 'resolve'>;
74
+ }
75
+ export interface LinkInput {
76
+ path: string;
77
+ data: Record<string, unknown>;
78
+ }
79
+ export interface SerializedLinkIndex {
80
+ pages: Array<Omit<IndexedPage, 'linkRefs' | 'linkedFromRefs' | 'linksTo' | 'linkedFrom'> & {
81
+ linksTo: string[] | LinkReference[];
82
+ linkedFrom: string[] | LinkReference[];
83
+ }>;
84
+ aliases: Record<string, string | LinkReference>;
85
+ }
86
+ export interface LinkDiagnostic {
87
+ file: string;
88
+ diagnostic: Diagnostic;
89
+ }
90
+ export declare function parseLinkFrontmatter(data: Record<string, unknown>, options?: LinkResolverOptions): ParseLinkResult;
91
+ export declare function buildLinkIndex(inputs: LinkInput[], options?: LinkResolverOptions): LinkIndex;
92
+ export declare function serializeLinkIndex(index: LinkIndex): SerializedLinkIndex;
93
+ export declare function aliasReplacementsForPage(index: LinkIndex, data: Record<string, unknown>): Map<string, string>;
94
+ export declare function validateLinks(inputs: LinkInput[], options?: LinkResolverOptions): LinkDiagnostic[];
95
+ export {};
96
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../src/links.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAEvB,OAAO,KAAK,EAAE,UAAU,EAAe,MAAM,kBAAkB,CAAA;AAO/D,QAAA,MAAM,UAAU;;;;mBAWd,CAAA;AAEF,QAAA,MAAM,eAAe;;;;;;;;;;;;;;;iBAQnB,CAAA;AAEF,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAA;AACnD,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,eAAe,CAAC,CAAA;AAE7D,MAAM,MAAM,eAAe,GACvB,aAAa,GACb,kBAAkB,GAClB,iBAAiB,GACjB,sCAAsC,CAAA;AAE1C,MAAM,WAAW,mBAAmB;IAClC,OAAO,CAAC,EAAE,eAAe,CAAA;IACzB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,MAAM,MAAM,eAAe,GACvB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,eAAe,GAAG,IAAI,CAAA;CAAE,GAC3C;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,MAAM,EAAE,CAAA;CAAE,CAAA;AAEnC,MAAM,WAAW,aAAa;IAC5B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;CACb;AAED,MAAM,WAAW,WAAW;IAC1B,IAAI,EAAE,MAAM,CAAA;IACZ,GAAG,CAAC,EAAE,MAAM,CAAA;IACZ,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,IAAI,EAAE,MAAM,CAAA;IACZ,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,QAAQ,CAAC,EAAE;QAAE,OAAO,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,EAAE,CAAA;KAAE,CAAA;IACrD,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,QAAQ,EAAE,aAAa,EAAE,CAAA;IACzB,cAAc,EAAE,aAAa,EAAE,CAAA;CAChC;AAED,MAAM,WAAW,cAAc;IAC7B,KAAK,EAAE,MAAM,CAAA;IACb,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,WAAW,EAAE,MAAM,CAAA;IACnB,IAAI,EAAE,WAAW,CAAA;CAClB;AAED,MAAM,WAAW,SAAS;IACxB,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,WAAW,CAAC,CAAA;IAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC5B,YAAY,EAAE,cAAc,EAAE,CAAA;IAC9B,OAAO,EAAE,QAAQ,CAAC,IAAI,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,mBAAmB,EAAE,SAAS,CAAC,CAAA;CAC/F;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAC9B;AAED,MAAM,WAAW,mBAAmB;IAClC,KAAK,EAAE,KAAK,CACV,IAAI,CAAC,WAAW,EAAE,UAAU,GAAG,gBAAgB,GAAG,SAAS,GAAG,YAAY,CAAC,GAAG;QAC5E,OAAO,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;QACnC,UAAU,EAAE,MAAM,EAAE,GAAG,aAAa,EAAE,CAAA;KACvC,CACF,CAAA;IACD,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,aAAa,CAAC,CAAA;CAChD;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAA;IACZ,UAAU,EAAE,UAAU,CAAA;CACvB;AAsBD,wBAAgB,oBAAoB,CAClC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC7B,OAAO,GAAE,mBAAwB,GAChC,eAAe,CAMjB;AAKD,wBAAgB,cAAc,CAAC,MAAM,EAAE,SAAS,EAAE,EAAE,OAAO,GAAE,mBAAwB,GAAG,SAAS,CA4DhG;AAKD,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,SAAS,GAAG,mBAAmB,CA6BxE;AAED,wBAAgB,wBAAwB,CACtC,KAAK,EAAE,SAAS,EAChB,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAC5B,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAWrB;AAMD,wBAAgB,aAAa,CAC3B,MAAM,EAAE,SAAS,EAAE,EACnB,OAAO,GAAE,mBAAwB,GAChC,cAAc,EAAE,CA0HlB"}
package/dist/links.js ADDED
@@ -0,0 +1,435 @@
1
+ import { z } from 'zod';
2
+ const Keywords = z.object({
3
+ primary: z.string().optional(),
4
+ secondary: z.array(z.string()).optional(),
5
+ });
6
+ const LinkTarget = z.union([
7
+ z.string(),
8
+ z
9
+ .object({
10
+ slug: z.string().min(1).optional(),
11
+ key: z.string().min(1).optional(),
12
+ locale: z.string().min(1).optional(),
13
+ })
14
+ .refine((target) => target.slug || target.key, {
15
+ message: 'object linksTo targets must include slug or key',
16
+ }),
17
+ ]);
18
+ const LinkFrontmatter = z.object({
19
+ slug: z.string().min(1),
20
+ key: z.string().min(1).optional(),
21
+ locale: z.string().min(1).optional(),
22
+ title: z.string().optional(),
23
+ linksTo: z.array(LinkTarget).optional(),
24
+ aliases: z.array(z.string()).optional(),
25
+ keywords: Keywords.optional(),
26
+ });
27
+ const DEFAULT_OPTIONS = {
28
+ resolve: 'global-slug',
29
+ };
30
+ const FM_POSITION = {
31
+ start: { line: 1, column: 1, offset: 0 },
32
+ end: { line: 1, column: 1, offset: 0 },
33
+ };
34
+ // Returns { value: null } when there is no `slug` (a non-participating page),
35
+ // parsed data when the link shape is valid, or shape errors otherwise. Never
36
+ // throws — callers turn errors into diagnostics.
37
+ export function parseLinkFrontmatter(data, options = {}) {
38
+ const normalized = normalizeFrontmatter(data, options);
39
+ if (!('slug' in normalized))
40
+ return { ok: true, value: null };
41
+ const parsed = LinkFrontmatter.safeParse(normalized);
42
+ if (parsed.success)
43
+ return { ok: true, value: parsed.data };
44
+ return { ok: false, errors: parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`) };
45
+ }
46
+ // Pure: builds the resolved link graph from per-file frontmatter data. Pass 1
47
+ // collects pages and registers aliases; pass 2 resolves each linksTo through
48
+ // the configured identity maps and inverts edges to derive linkedFrom.
49
+ export function buildLinkIndex(inputs, options = {}) {
50
+ const resolvedOptions = { ...DEFAULT_OPTIONS, ...options };
51
+ const pages = new Map();
52
+ const aliases = new Map();
53
+ const aliasEntries = [];
54
+ for (const { path, data } of inputs) {
55
+ const parsed = parseLinkFrontmatter(data, resolvedOptions);
56
+ if (!parsed.ok || parsed.value === null)
57
+ continue;
58
+ const fm = parsed.value;
59
+ const page = {
60
+ slug: fm.slug,
61
+ key: fm.key,
62
+ locale: effectiveLocale(fm, resolvedOptions),
63
+ path,
64
+ title: fm.title,
65
+ keywords: fm.keywords,
66
+ linksTo: [],
67
+ linkedFrom: [],
68
+ aliases: fm.aliases ?? [],
69
+ linkRefs: [],
70
+ linkedFromRefs: [],
71
+ };
72
+ pages.set(pageMapKey(page, resolvedOptions), page);
73
+ }
74
+ for (const page of pages.values()) {
75
+ for (const alias of page.aliases) {
76
+ const replacement = replacementFor(page, resolvedOptions);
77
+ const key = aliasMapKey(alias, page.locale, resolvedOptions);
78
+ aliases.set(key, replacement);
79
+ aliasEntries.push({ alias, locale: page.locale, replacement, page });
80
+ }
81
+ }
82
+ const lookup = buildLookup(pages, resolvedOptions);
83
+ for (const page of pages.values()) {
84
+ const source = parseLinkFrontmatter(inputForPage(page, inputs, resolvedOptions), resolvedOptions);
85
+ if (!source.ok || source.value === null)
86
+ continue;
87
+ for (const rawTarget of source.value.linksTo ?? []) {
88
+ const resolved = resolveTarget(rawTarget, page, lookup, resolvedOptions);
89
+ if (!resolved.page) {
90
+ page.linksTo.push(resolved.target);
91
+ continue;
92
+ }
93
+ page.linksTo.push(replacementFor(resolved.page, resolvedOptions));
94
+ page.linkRefs.push(referenceFor(resolved.page, resolved.target));
95
+ if (resolved.page === page)
96
+ continue;
97
+ const from = replacementFor(page, resolvedOptions);
98
+ if (!resolved.page.linkedFrom.includes(from))
99
+ resolved.page.linkedFrom.push(from);
100
+ if (!resolved.page.linkedFromRefs.some((r) => sameReference(r, page))) {
101
+ resolved.page.linkedFromRefs.push(referenceFor(page));
102
+ }
103
+ }
104
+ }
105
+ return { pages, aliases, aliasEntries, options: resolvedOptions };
106
+ }
107
+ // Stable, sorted JSON form for .contentbit/link-index.json. Global-slug projects
108
+ // keep the original compact string arrays; locale/key projects receive richer
109
+ // references so agents can see which local target was resolved.
110
+ export function serializeLinkIndex(index) {
111
+ const scoped = [...index.pages.values()].some((page) => page.locale || page.key);
112
+ const pages = [...index.pages.values()]
113
+ .map((p) => {
114
+ const base = {
115
+ slug: p.slug,
116
+ ...(p.key ? { key: p.key } : {}),
117
+ ...(p.locale ? { locale: p.locale } : {}),
118
+ path: p.path,
119
+ ...(p.title ? { title: p.title } : {}),
120
+ ...(p.keywords ? { keywords: p.keywords } : {}),
121
+ linksTo: scoped ? p.linkRefs : [...p.linksTo],
122
+ linkedFrom: scoped ? sortedRefs(p.linkedFromRefs) : [...p.linkedFrom].sort(),
123
+ aliases: [...p.aliases],
124
+ };
125
+ return base;
126
+ })
127
+ .sort((a, b) => sortIdentity(a.locale, a.slug).localeCompare(sortIdentity(b.locale, b.slug)));
128
+ const aliases = {};
129
+ if (scoped) {
130
+ for (const entry of [...index.aliasEntries].sort((a, b) => sortIdentity(a.locale, a.alias).localeCompare(sortIdentity(b.locale, b.alias)))) {
131
+ aliases[aliasMapKey(entry.alias, entry.locale, index.options)] = referenceFor(entry.page);
132
+ }
133
+ }
134
+ else {
135
+ for (const key of [...index.aliases.keys()].sort())
136
+ aliases[key] = index.aliases.get(key);
137
+ }
138
+ return { pages, aliases };
139
+ }
140
+ export function aliasReplacementsForPage(index, data) {
141
+ const parsed = parseLinkFrontmatter(data, index.options);
142
+ const out = new Map();
143
+ if (!parsed.ok || parsed.value === null)
144
+ return out;
145
+ const locale = effectiveLocale(parsed.value, index.options);
146
+ for (const entry of index.aliasEntries) {
147
+ if (index.options.resolve === 'global-slug' || entry.locale === locale) {
148
+ out.set(entry.alias, entry.replacement);
149
+ }
150
+ }
151
+ return out;
152
+ }
153
+ // Cross-file link validation. Emits shape errors, duplicate-slug and
154
+ // alias-conflict errors, dangling-link errors (with did-you-mean), and
155
+ // self-link / orphan warnings. Returns file-tagged diagnostics so the CLI can
156
+ // format each with its own filename.
157
+ export function validateLinks(inputs, options = {}) {
158
+ const resolvedOptions = { ...DEFAULT_OPTIONS, ...options };
159
+ const out = [];
160
+ const validInputs = [];
161
+ const seenSlug = new Map(); // identity -> first file
162
+ const seenKey = new Map(); // identity -> first file
163
+ const seenAlias = new Map(); // identity -> file
164
+ // Shape + duplicate/alias-conflict pass (operates on raw inputs).
165
+ for (const { path, data } of inputs) {
166
+ const parsed = parseLinkFrontmatter(data, resolvedOptions);
167
+ if (!parsed.ok) {
168
+ for (const e of parsed.errors)
169
+ out.push(diag(path, 'CB_LINK_SHAPE', 'error', `invalid link frontmatter: ${e}`));
170
+ continue;
171
+ }
172
+ if (parsed.value === null)
173
+ continue;
174
+ const fm = parsed.value;
175
+ const locale = effectiveLocale(fm, resolvedOptions);
176
+ validInputs.push({ path, fm });
177
+ const slugKey = scopedKey(fm.slug, locale, resolvedOptions);
178
+ const prior = seenSlug.get(slugKey);
179
+ if (prior)
180
+ out.push(diag(path, 'CB_SLUG_DUPLICATE', 'error', `slug "${fm.slug}" also used by ${prior}`));
181
+ else
182
+ seenSlug.set(slugKey, path);
183
+ if (usesKeyResolution(resolvedOptions) && !fm.key) {
184
+ out.push(diag(path, 'CB_KEY_MISSING', 'error', `page "${fm.slug}" is missing key`));
185
+ }
186
+ if (fm.key) {
187
+ const keyKey = scopedKey(fm.key, locale, resolvedOptions);
188
+ const priorKey = seenKey.get(keyKey);
189
+ if (priorKey)
190
+ out.push(diag(path, 'CB_KEY_DUPLICATE', 'error', `key "${fm.key}" also used by ${priorKey}`));
191
+ else
192
+ seenKey.set(keyKey, path);
193
+ }
194
+ for (const alias of fm.aliases ?? []) {
195
+ const aliasKey = scopedKey(alias, locale, resolvedOptions);
196
+ if (seenAlias.has(aliasKey))
197
+ out.push(diag(path, 'CB_ALIAS_CONFLICT', 'error', `alias "${alias}" already declared by ${seenAlias.get(aliasKey)}`));
198
+ else
199
+ seenAlias.set(aliasKey, path);
200
+ }
201
+ }
202
+ const index = buildLinkIndex(inputs, resolvedOptions);
203
+ const lookup = buildLookup(index.pages, resolvedOptions);
204
+ for (const { path, fm } of validInputs) {
205
+ const page = index.pages.get(pageMapKey(frontmatterIdentity(fm, resolvedOptions), resolvedOptions));
206
+ if (!page)
207
+ continue;
208
+ // alias colliding with a real slug/key in the same scope
209
+ for (const alias of page.aliases) {
210
+ if (collidesWithPageIdentity(alias, page.locale, lookup, resolvedOptions))
211
+ out.push(diag(page.path, 'CB_ALIAS_CONFLICT', 'error', `alias "${alias}" collides with an existing page identity`));
212
+ }
213
+ for (const target of fm.linksTo ?? []) {
214
+ const resolved = resolveTarget(target, page, lookup, resolvedOptions);
215
+ if (resolved.page === page) {
216
+ out.push(diag(page.path, 'CB_LINK_SELF', 'warning', `page "${page.slug}" links to itself`));
217
+ continue;
218
+ }
219
+ if (resolved.page && resolved.crossLocale) {
220
+ out.push(diag(page.path, 'CB_LINK_CROSS_LOCALE', 'warning', `linksTo "${resolved.target}" resolves to locale "${resolved.page.locale}"`));
221
+ continue;
222
+ }
223
+ if (!resolved.page) {
224
+ if (targetExistsOutsideLocale(resolved.target, page.locale, lookup, resolvedOptions)) {
225
+ out.push(diag(page.path, 'CB_LINK_LOCALE_MISSING', 'error', `linksTo "${resolved.target}" exists in another locale but not "${page.locale ?? 'default'}"`));
226
+ continue;
227
+ }
228
+ const hint = closest(resolved.target, candidatesFor(page.locale, lookup, resolvedOptions));
229
+ out.push(diag(page.path, 'CB_LINK_UNRESOLVED', 'error', `linksTo "${resolved.target}" does not resolve to any page`, hint ? `Did you mean "${hint}"?` : undefined));
230
+ }
231
+ }
232
+ if (page.linkedFrom.length === 0)
233
+ out.push(diag(page.path, 'CB_LINK_ORPHAN', 'warning', `page "${page.slug}" has no inbound links`));
234
+ }
235
+ return out;
236
+ }
237
+ function normalizeFrontmatter(data, options) {
238
+ const out = { ...data };
239
+ copyConfiguredField(out, data, options.slugField, 'slug');
240
+ copyConfiguredField(out, data, options.keyField, 'key');
241
+ copyConfiguredField(out, data, options.localeField, 'locale');
242
+ return out;
243
+ }
244
+ function copyConfiguredField(out, data, from, to) {
245
+ if (!from || from === to || !(from in data) || to in out)
246
+ return;
247
+ out[to] = data[from];
248
+ }
249
+ function effectiveLocale(fm, options) {
250
+ return fm.locale ?? options.defaultLocale;
251
+ }
252
+ function frontmatterIdentity(fm, options) {
253
+ return { slug: fm.slug, key: fm.key, locale: effectiveLocale(fm, options) };
254
+ }
255
+ function pageMapKey(page, options) {
256
+ if (options.resolve === 'global-slug')
257
+ return page.slug;
258
+ return scopedKey(page.slug, page.locale, options);
259
+ }
260
+ function scopedKey(value, locale, options) {
261
+ if (options.resolve === 'global-slug')
262
+ return value;
263
+ return `${locale ?? ''}\0${value}`;
264
+ }
265
+ function aliasMapKey(alias, locale, options) {
266
+ return options.resolve === 'global-slug' ? alias : `${locale ?? ''}:${alias}`;
267
+ }
268
+ function replacementFor(page, options) {
269
+ if (options.resolve === 'same-locale-key')
270
+ return page.key ?? page.slug;
271
+ if (options.resolve === 'prefer-same-locale-key-fallback-slug')
272
+ return page.key ?? page.slug;
273
+ return page.slug;
274
+ }
275
+ function referenceFor(page, target) {
276
+ return {
277
+ ...(target ? { target } : {}),
278
+ ...(page.locale ? { locale: page.locale } : {}),
279
+ slug: page.slug,
280
+ ...(page.key ? { key: page.key } : {}),
281
+ };
282
+ }
283
+ function sameReference(ref, page) {
284
+ return ref.slug === page.slug && ref.locale === page.locale && ref.key === page.key;
285
+ }
286
+ function sortedRefs(refs) {
287
+ return [...refs].sort((a, b) => sortIdentity(a.locale, a.key ?? a.slug).localeCompare(sortIdentity(b.locale, b.key ?? b.slug)));
288
+ }
289
+ function sortIdentity(locale, value) {
290
+ return `${locale ?? ''}\0${value}`;
291
+ }
292
+ function buildLookup(pages, options) {
293
+ const lookup = {
294
+ bySlug: new Map(),
295
+ byScopedSlug: new Map(),
296
+ byKey: new Map(),
297
+ byScopedKey: new Map(),
298
+ aliasBySlug: new Map(),
299
+ aliasByScopedSlug: new Map(),
300
+ aliasByKey: new Map(),
301
+ aliasByScopedKey: new Map(),
302
+ };
303
+ for (const page of pages.values()) {
304
+ pushMulti(lookup.bySlug, page.slug, page);
305
+ lookup.byScopedSlug.set(scopedKey(page.slug, page.locale, options), page);
306
+ if (page.key) {
307
+ pushMulti(lookup.byKey, page.key, page);
308
+ lookup.byScopedKey.set(scopedKey(page.key, page.locale, options), page);
309
+ }
310
+ for (const alias of page.aliases) {
311
+ lookup.aliasBySlug.set(alias, page);
312
+ lookup.aliasByScopedSlug.set(scopedKey(alias, page.locale, options), page);
313
+ if (page.key) {
314
+ lookup.aliasByKey.set(alias, page);
315
+ lookup.aliasByScopedKey.set(scopedKey(alias, page.locale, options), page);
316
+ }
317
+ }
318
+ }
319
+ return lookup;
320
+ }
321
+ function pushMulti(map, key, page) {
322
+ const existing = map.get(key);
323
+ if (existing)
324
+ existing.push(page);
325
+ else
326
+ map.set(key, [page]);
327
+ }
328
+ function resolveTarget(rawTarget, source, lookup, options) {
329
+ if (typeof rawTarget !== 'string') {
330
+ const locale = rawTarget.locale ?? source.locale;
331
+ const page = rawTarget.key
332
+ ? lookup.byScopedKey.get(scopedKey(rawTarget.key, locale, options))
333
+ : rawTarget.slug
334
+ ? lookup.byScopedSlug.get(scopedKey(rawTarget.slug, locale, options))
335
+ : undefined;
336
+ const target = rawTarget.key ?? rawTarget.slug ?? '';
337
+ return {
338
+ page,
339
+ target,
340
+ explicitLocale: rawTarget.locale,
341
+ crossLocale: Boolean(page && rawTarget.locale && rawTarget.locale !== source.locale),
342
+ matchedBy: rawTarget.key ? 'key' : 'slug',
343
+ };
344
+ }
345
+ const locale = source.locale;
346
+ if (options.resolve === 'global-slug') {
347
+ const page = lookup.aliasBySlug.get(rawTarget) ?? single(lookup.bySlug.get(rawTarget));
348
+ return { page, target: rawTarget, crossLocale: false, matchedBy: page ? 'slug' : undefined };
349
+ }
350
+ if (options.resolve === 'same-locale-key') {
351
+ const scoped = scopedKey(rawTarget, locale, options);
352
+ const page = lookup.byScopedKey.get(scoped) ?? lookup.aliasByScopedKey.get(scoped);
353
+ return { page, target: rawTarget, crossLocale: false, matchedBy: page ? 'key' : undefined };
354
+ }
355
+ if (options.resolve === 'prefer-same-locale-key-fallback-slug') {
356
+ const scoped = scopedKey(rawTarget, locale, options);
357
+ const page = lookup.byScopedKey.get(scoped) ??
358
+ lookup.aliasByScopedKey.get(scoped) ??
359
+ lookup.byScopedSlug.get(scoped) ??
360
+ lookup.aliasByScopedSlug.get(scoped);
361
+ return { page, target: rawTarget, crossLocale: false, matchedBy: page ? 'key' : undefined };
362
+ }
363
+ const scoped = scopedKey(rawTarget, locale, options);
364
+ const page = lookup.byScopedSlug.get(scoped) ?? lookup.aliasByScopedSlug.get(scoped);
365
+ return { page, target: rawTarget, crossLocale: false, matchedBy: page ? 'slug' : undefined };
366
+ }
367
+ function single(values) {
368
+ return values?.length === 1 ? values[0] : undefined;
369
+ }
370
+ function usesKeyResolution(options) {
371
+ return options.resolve === 'same-locale-key';
372
+ }
373
+ function collidesWithPageIdentity(alias, locale, lookup, options) {
374
+ if (options.resolve === 'global-slug')
375
+ return lookup.bySlug.has(alias);
376
+ const scoped = scopedKey(alias, locale, options);
377
+ return (lookup.byScopedSlug.has(scoped) ||
378
+ (usesKeyResolution(options) && lookup.byScopedKey.has(scoped)));
379
+ }
380
+ function targetExistsOutsideLocale(target, locale, lookup, options) {
381
+ if (options.resolve === 'global-slug')
382
+ return false;
383
+ const byIdentity = usesKeyResolution(options) ? lookup.byKey : lookup.bySlug;
384
+ return (byIdentity.get(target) ?? []).some((page) => page.locale !== locale);
385
+ }
386
+ function candidatesFor(locale, lookup, options) {
387
+ if (options.resolve === 'global-slug')
388
+ return [...lookup.bySlug.keys()];
389
+ const out = [];
390
+ for (const page of lookup.byScopedSlug.values()) {
391
+ if (page.locale === locale)
392
+ out.push(usesKeyResolution(options) && page.key ? page.key : page.slug);
393
+ }
394
+ return out;
395
+ }
396
+ function inputForPage(page, inputs, options) {
397
+ for (const input of inputs) {
398
+ const parsed = parseLinkFrontmatter(input.data, options);
399
+ if (!parsed.ok || parsed.value === null)
400
+ continue;
401
+ const identity = frontmatterIdentity(parsed.value, options);
402
+ if (pageMapKey(identity, options) === pageMapKey(page, options))
403
+ return input.data;
404
+ }
405
+ return {};
406
+ }
407
+ function diag(file, code, severity, message, hint) {
408
+ return { file, diagnostic: { code, severity, message, hint, position: FM_POSITION } };
409
+ }
410
+ // Levenshtein distance for did-you-mean hints. Small inputs (slugs), so the
411
+ // simple O(n*m) matrix is fine.
412
+ function editDistance(a, b) {
413
+ const dp = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
414
+ for (let j = 0; j <= b.length; j++)
415
+ dp[0][j] = j;
416
+ for (let i = 1; i <= a.length; i++) {
417
+ for (let j = 1; j <= b.length; j++) {
418
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
419
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
420
+ }
421
+ }
422
+ return dp[a.length][b.length];
423
+ }
424
+ function closest(target, candidates) {
425
+ let best;
426
+ let bestD = Infinity;
427
+ for (const c of candidates) {
428
+ const d = editDistance(target, c);
429
+ if (d < bestD) {
430
+ bestD = d;
431
+ best = c;
432
+ }
433
+ }
434
+ return best && bestD <= Math.max(2, Math.floor(target.length / 3)) ? best : undefined;
435
+ }
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.1.0";
1
+ export declare const VERSION = "0.2.0";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const VERSION = '0.1.0';
1
+ export const VERSION = '0.2.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contentbit/core",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
4
  "description": "Parser, validator, and registry for Content Blocks: structured Markdown components without framework lock-in.",
5
5
  "keywords": [
6
6
  "ast",