@contentbit/core 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.
@@ -68,12 +68,64 @@ function parseValue(value, indented) {
68
68
  if (indented.length === 0)
69
69
  return null;
70
70
  const items = dedent(indented);
71
- if (items.every((l) => l.startsWith('- ')))
72
- return items.map((l) => parseScalar(l.slice(2).trim()));
71
+ const list = parseDashList(items);
72
+ if (list)
73
+ return list;
74
+ const mapping = parseNestedMapping(items);
75
+ if (mapping)
76
+ return mapping;
73
77
  return items.join('\n');
74
78
  }
75
79
  return parseScalar(value);
76
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
+ }
77
129
  function dedent(lines) {
78
130
  const indent = Math.min(...lines.map((l) => l.match(/^[ \t]*/)[0].length));
79
131
  return lines.map((l) => l.slice(indent));
@@ -106,6 +158,20 @@ function parseScalar(value) {
106
158
  return [];
107
159
  return splitInlineItems(inner).map((item) => parseScalar(item.trim()));
108
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
+ }
109
175
  return value;
110
176
  }
111
177
  /** Split inline array items on top-level commas, respecting quotes. */
package/dist/index.d.ts CHANGED
@@ -12,4 +12,5 @@ export { markdownBody, pipeRows, listItems, childBlocks, type MarkdownBodyData,
12
12
  export { validateDocument, isValidatedBlock, type ValidateOptions, type ValidationResult, type ValidatedBlockNode, } from './validate.js';
13
13
  export { generateAuthoringGuide, type AuthoringGuideOptions } from './authoring.js';
14
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';
15
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,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"}
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
@@ -10,3 +10,4 @@ export { markdownBody, pipeRows, listItems, childBlocks, } from './content-model
10
10
  export { validateDocument, isValidatedBlock, } from './validate.js';
11
11
  export { generateAuthoringGuide } from './authoring.js';
12
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":"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"}
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.2.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",