@contentbit/core 0.1.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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 contentbit
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,15 @@
1
+ # @contentbit/core
2
+
3
+ Parser, validator, and registry for [Content Blocks](https://contentbit.dev): structured Markdown components without framework lock-in.
4
+
5
+ ```ts
6
+ import { createBlockRegistry, parseDocument, validateDocument } from '@contentbit/core'
7
+ import { genericBlocks } from '@contentbit/blocks'
8
+
9
+ const registry = createBlockRegistry().use(genericBlocks())
10
+ const result = validateDocument(parseDocument(markdown), registry)
11
+ ```
12
+
13
+ Documents are plain Markdown with directive blocks. Validation runs before rendering and produces `file:line:col` diagnostics a human or an LLM can act on. The same registry generates LLM authoring instructions via `toAuthoringGuide()`.
14
+
15
+ Docs: [contentbit.dev/docs](https://contentbit.dev/docs)
package/dist/ast.d.ts ADDED
@@ -0,0 +1,29 @@
1
+ import type { SourceRange } from './diagnostics.js';
2
+ export interface DocumentNode {
3
+ type: 'document';
4
+ children: ContentNode[];
5
+ position: SourceRange;
6
+ }
7
+ export type ContentNode = MarkdownNode | BlockNode;
8
+ /** A verbatim run of plain Markdown between blocks. Core never parses inside it. */
9
+ export interface MarkdownNode {
10
+ type: 'markdown';
11
+ value: string;
12
+ position: SourceRange;
13
+ }
14
+ export interface BlockNode {
15
+ type: 'block';
16
+ name: string;
17
+ /** Number of colons in the opening fence: >=3 container, 2 child. */
18
+ fence: number;
19
+ props: Record<string, unknown>;
20
+ rawProps: string | null;
21
+ children: ContentNode[];
22
+ /** Raw inner source between open and close lines (includes nested block text). */
23
+ body: string;
24
+ position: SourceRange;
25
+ openPosition: SourceRange;
26
+ /** null for implicitly closed child blocks and unclosed blocks. */
27
+ closePosition: SourceRange | null;
28
+ }
29
+ //# sourceMappingURL=ast.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ast.d.ts","sourceRoot":"","sources":["../src/ast.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAEnD,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAA;IAChB,QAAQ,EAAE,WAAW,EAAE,CAAA;IACvB,QAAQ,EAAE,WAAW,CAAA;CACtB;AAED,MAAM,MAAM,WAAW,GAAG,YAAY,GAAG,SAAS,CAAA;AAElD,oFAAoF;AACpF,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,UAAU,CAAA;IAChB,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,EAAE,WAAW,CAAA;CACtB;AAED,MAAM,WAAW,SAAS;IACxB,IAAI,EAAE,OAAO,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAA;IACvB,QAAQ,EAAE,WAAW,EAAE,CAAA;IACvB,kFAAkF;IAClF,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,WAAW,CAAA;IACrB,YAAY,EAAE,WAAW,CAAA;IACzB,mEAAmE;IACnE,aAAa,EAAE,WAAW,GAAG,IAAI,CAAA;CAClC"}
package/dist/ast.js ADDED
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ import type { BlockDefinition } from './registry.js';
2
+ export interface AuthoringGuideOptions {
3
+ audience?: 'llm' | 'human';
4
+ includeExamples?: boolean;
5
+ includeAvoidRules?: boolean;
6
+ }
7
+ export declare function generateAuthoringGuide(defs: BlockDefinition<unknown>[], opts?: AuthoringGuideOptions): string;
8
+ //# sourceMappingURL=authoring.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,33 @@
1
+ const LLM_PREAMBLE = `# Content block authoring rules
2
+
3
+ - write regular Markdown by default; use blocks only when they improve scanning or structure
4
+ - never invent block names — only the blocks documented below exist
5
+ - follow each block's syntax exactly; props use {key="value"} on the open line
6
+ - close every :::block with a line containing only :::
7
+ - validate generated content and fix diagnostics rather than bypassing them
8
+ `;
9
+ export function generateAuthoringGuide(defs, opts = {}) {
10
+ const includeExamples = opts.includeExamples ?? true;
11
+ const includeAvoid = opts.includeAvoidRules ?? true;
12
+ const sections = [];
13
+ if ((opts.audience ?? 'llm') === 'llm')
14
+ sections.push(LLM_PREAMBLE);
15
+ for (const def of defs) {
16
+ const lines = [`## ${def.name}`, ''];
17
+ lines.push(def.childOnly
18
+ ? `${def.description} (child block — only inside a parent that allows it)`
19
+ : def.description);
20
+ lines.push('', `Content: ${def.content.describe()}`);
21
+ if (def.authoring.useWhen.length > 0) {
22
+ lines.push('', 'Use when:', ...def.authoring.useWhen.map((u) => `- ${u}`));
23
+ }
24
+ if (includeAvoid && def.authoring.avoidWhen.length > 0) {
25
+ lines.push('', 'Avoid when:', ...def.authoring.avoidWhen.map((a) => `- ${a}`));
26
+ }
27
+ if (includeExamples && def.authoring.example.trim() !== '') {
28
+ lines.push('', 'Example:', '', '```md', def.authoring.example, '```');
29
+ }
30
+ sections.push(lines.join('\n'));
31
+ }
32
+ return sections.join('\n\n') + '\n';
33
+ }
@@ -0,0 +1,48 @@
1
+ import type { BlockNode } from './ast.js';
2
+ import type { ContentModel } from './registry.js';
3
+ export interface MarkdownBodyOptions {
4
+ required?: boolean;
5
+ minLength?: number;
6
+ maxLength?: number;
7
+ }
8
+ export interface MarkdownBodyData {
9
+ markdown: string;
10
+ }
11
+ export declare function markdownBody(opts?: MarkdownBodyOptions): ContentModel<MarkdownBodyData>;
12
+ export interface PipeRowsOptions {
13
+ columns: string[];
14
+ /** Number of trailing columns that may be omitted (filled with ""). */
15
+ optionalColumns?: number;
16
+ minRows?: number;
17
+ maxRows?: number;
18
+ allowMarkdown?: boolean;
19
+ }
20
+ export interface PipeRowsData {
21
+ rows: Array<Record<string, string>>;
22
+ }
23
+ export declare function pipeRows(opts: PipeRowsOptions): ContentModel<PipeRowsData>;
24
+ export interface ListItemsOptions {
25
+ /** signed = lines starting with `+ ` or `- ` (e.g. pros-cons). */
26
+ marker: 'bullet' | 'ordered' | 'signed';
27
+ minItems?: number;
28
+ maxItems?: number;
29
+ }
30
+ export interface ListItem {
31
+ text: string;
32
+ sign?: '+' | '-';
33
+ }
34
+ export interface ListItemsData {
35
+ items: ListItem[];
36
+ }
37
+ export declare function listItems(opts: ListItemsOptions): ContentModel<ListItemsData>;
38
+ export interface ChildBlocksOptions {
39
+ allowed: string[];
40
+ required?: string[];
41
+ minChildren?: number;
42
+ maxChildren?: number;
43
+ }
44
+ export interface ChildBlocksData {
45
+ blocks: BlockNode[];
46
+ }
47
+ export declare function childBlocks(opts: ChildBlocksOptions): ContentModel<ChildBlocksData>;
48
+ //# sourceMappingURL=content-models.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"content-models.d.ts","sourceRoot":"","sources":["../src/content-models.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,YAAY,EAAU,MAAM,eAAe,CAAA;AAgBzD,MAAM,WAAW,mBAAmB;IAClC,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,SAAS,CAAC,EAAE,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,gBAAgB;IAC/B,QAAQ,EAAE,MAAM,CAAA;CACjB;AAED,wBAAgB,YAAY,CAAC,IAAI,GAAE,mBAAwB,GAAG,YAAY,CAAC,gBAAgB,CAAC,CAkC3F;AAID,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,uEAAuE;IACvE,eAAe,CAAC,EAAE,MAAM,CAAA;IACxB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,aAAa,CAAC,EAAE,OAAO,CAAA;CACxB;AAED,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,KAAK,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CACpC;AASD,wBAAgB,QAAQ,CAAC,IAAI,EAAE,eAAe,GAAG,YAAY,CAAC,YAAY,CAAC,CA8D1E;AAID,MAAM,WAAW,gBAAgB;IAC/B,kEAAkE;IAClE,MAAM,EAAE,QAAQ,GAAG,SAAS,GAAG,QAAQ,CAAA;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAA;CAClB;AAED,MAAM,WAAW,QAAQ;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,CAAC,EAAE,GAAG,GAAG,GAAG,CAAA;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,KAAK,EAAE,QAAQ,EAAE,CAAA;CAClB;AAQD,wBAAgB,SAAS,CAAC,IAAI,EAAE,gBAAgB,GAAG,YAAY,CAAC,aAAa,CAAC,CAkD7E;AAID,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAA;IACnB,WAAW,CAAC,EAAE,MAAM,CAAA;IACpB,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,SAAS,EAAE,CAAA;CACpB;AAED,wBAAgB,WAAW,CAAC,IAAI,EAAE,kBAAkB,GAAG,YAAY,CAAC,eAAe,CAAC,CA0DnF"}
@@ -0,0 +1,174 @@
1
+ import { bodyLineRange } from './position.js';
2
+ function blockDiag(node, code, message, hint, severity = 'error') {
3
+ return { code, severity, message, hint, blockName: node.name, position: node.openPosition };
4
+ }
5
+ export function markdownBody(opts = {}) {
6
+ const required = opts.required ?? true;
7
+ return {
8
+ kind: 'markdown',
9
+ describe: () => 'Markdown body',
10
+ parse(node, report) {
11
+ const markdown = node.body.trim();
12
+ if (required && markdown === '') {
13
+ report(blockDiag(node, 'CB_BODY_EMPTY', `:::${node.name} requires a body.`));
14
+ }
15
+ else if (opts.minLength !== undefined && markdown.length < opts.minLength) {
16
+ report(blockDiag(node, 'CB_BODY_LENGTH', `:::${node.name} body is shorter than ${opts.minLength} characters.`, undefined, 'warning'));
17
+ }
18
+ if (opts.maxLength !== undefined && markdown.length > opts.maxLength) {
19
+ report(blockDiag(node, 'CB_BODY_LENGTH', `:::${node.name} body exceeds ${opts.maxLength} characters.`, undefined, 'warning'));
20
+ }
21
+ return { markdown };
22
+ },
23
+ };
24
+ }
25
+ const ROW_RE = /^\s*-\s+(.*)$/;
26
+ // Known limitation: a cell value ending in a literal backslash immediately
27
+ // before a separator pipe (e.g. `foo\\|bar`) is treated as an escaped pipe
28
+ // rather than a column separator. Write `\|` only to escape a pipe character;
29
+ // avoid a trailing `\` at the end of a cell value.
30
+ const UNESCAPED_PIPE = /(?<!\\)\|/;
31
+ export function pipeRows(opts) {
32
+ const required = opts.columns.length - (opts.optionalColumns ?? 0);
33
+ return {
34
+ kind: 'rows',
35
+ describe: () => `List rows: \`- ${opts.columns.join(' | ')}\`${opts.optionalColumns ? ` (last ${opts.optionalColumns} optional)` : ''}`,
36
+ parse(node, report) {
37
+ const rows = [];
38
+ const bodyLines = node.body.split('\n');
39
+ for (let i = 0; i < bodyLines.length; i++) {
40
+ const line = bodyLines[i];
41
+ if (line.trim() === '')
42
+ continue;
43
+ const m = line.match(ROW_RE);
44
+ if (!m) {
45
+ report({
46
+ code: 'CB_ROW_SYNTAX',
47
+ severity: 'warning',
48
+ message: `:::${node.name} rows must start with "- ". Ignored: "${line.trim()}".`,
49
+ blockName: node.name,
50
+ position: bodyLineRange(node, i),
51
+ });
52
+ continue;
53
+ }
54
+ const cells = m[1].split(UNESCAPED_PIPE).map((c) => c.replace(/\\\|/g, '|').trim());
55
+ if (cells.length < required || cells.length > opts.columns.length) {
56
+ report({
57
+ code: 'CB_ROW_COLUMNS',
58
+ severity: 'error',
59
+ message: `:::${node.name} rows require ${opts.columns.length} columns (${opts.columns.join(' | ')}). Found ${cells.length}.`,
60
+ hint: `Format: - ${opts.columns.join(' | ')}`,
61
+ blockName: node.name,
62
+ position: bodyLineRange(node, i),
63
+ });
64
+ continue;
65
+ }
66
+ const row = {};
67
+ opts.columns.forEach((col, idx) => {
68
+ row[col] = cells[idx] ?? '';
69
+ });
70
+ rows.push(row);
71
+ }
72
+ if (opts.minRows !== undefined && rows.length < opts.minRows) {
73
+ report(blockDiag(node, 'CB_ROW_COUNT', `:::${node.name} needs at least ${opts.minRows} rows, found ${rows.length}.`));
74
+ }
75
+ if (opts.maxRows !== undefined && rows.length > opts.maxRows) {
76
+ report(blockDiag(node, 'CB_ROW_COUNT', `:::${node.name} allows at most ${opts.maxRows} rows, found ${rows.length}.`));
77
+ }
78
+ return { rows };
79
+ },
80
+ };
81
+ }
82
+ const MARKERS = {
83
+ bullet: /^\s*-\s+(.*)$/,
84
+ ordered: /^\s*\d+[.)]\s+(.*)$/,
85
+ signed: /^\s*([+-])\s+(.*)$/,
86
+ };
87
+ export function listItems(opts) {
88
+ return {
89
+ kind: 'list',
90
+ describe: () => opts.marker === 'ordered'
91
+ ? 'Ordered list: `1. item`'
92
+ : opts.marker === 'signed'
93
+ ? 'Signed list: `+ positive` / `- negative`'
94
+ : 'Bullet list: `- item`',
95
+ parse(node, report) {
96
+ const items = [];
97
+ const bodyLines = node.body.split('\n');
98
+ for (let i = 0; i < bodyLines.length; i++) {
99
+ const line = bodyLines[i];
100
+ if (line.trim() === '')
101
+ continue;
102
+ const m = line.match(MARKERS[opts.marker]);
103
+ if (!m) {
104
+ report({
105
+ code: 'CB_ITEM_SYNTAX',
106
+ severity: 'warning',
107
+ message: `:::${node.name} expects ${opts.marker} list items. Ignored: "${line.trim()}".`,
108
+ blockName: node.name,
109
+ position: bodyLineRange(node, i),
110
+ });
111
+ continue;
112
+ }
113
+ if (opts.marker === 'signed')
114
+ items.push({ text: m[2], sign: m[1] });
115
+ else
116
+ items.push({ text: m[1] });
117
+ }
118
+ if (opts.minItems !== undefined && items.length < opts.minItems) {
119
+ report(blockDiag(node, 'CB_ITEM_COUNT', `:::${node.name} needs at least ${opts.minItems} items, found ${items.length}.`));
120
+ }
121
+ if (opts.maxItems !== undefined && items.length > opts.maxItems) {
122
+ report(blockDiag(node, 'CB_ITEM_COUNT', `:::${node.name} allows at most ${opts.maxItems} items, found ${items.length}.`));
123
+ }
124
+ return { items };
125
+ },
126
+ };
127
+ }
128
+ export function childBlocks(opts) {
129
+ return {
130
+ kind: 'children',
131
+ describe: () => `Child blocks: ${opts.allowed.map((n) => `\`::${n}\``).join(', ')}`,
132
+ parse(node, report) {
133
+ const blocks = [];
134
+ for (const child of node.children) {
135
+ if (child.type === 'markdown') {
136
+ if (child.value.trim() !== '') {
137
+ report({
138
+ code: 'CB_UNEXPECTED_CONTENT',
139
+ severity: 'warning',
140
+ message: `:::${node.name} only accepts ${opts.allowed.map((n) => `::${n}`).join(', ')} children; loose text is ignored.`,
141
+ blockName: node.name,
142
+ position: child.position,
143
+ });
144
+ }
145
+ continue;
146
+ }
147
+ if (!opts.allowed.includes(child.name)) {
148
+ report({
149
+ code: 'CB_CHILD_NOT_ALLOWED',
150
+ severity: 'error',
151
+ message: `"::${child.name}" is not allowed inside :::${node.name}. Allowed: ${opts.allowed.join(', ')}.`,
152
+ blockName: node.name,
153
+ position: child.openPosition,
154
+ });
155
+ continue;
156
+ }
157
+ blocks.push(child);
158
+ }
159
+ for (const name of opts.required ?? []) {
160
+ if (!blocks.some((b) => b.name === name)) {
161
+ report(blockDiag(node, 'CB_CHILD_MISSING', `:::${node.name} requires a ::${name} child.`));
162
+ }
163
+ }
164
+ const count = blocks.length;
165
+ if (opts.minChildren !== undefined && count < opts.minChildren) {
166
+ report(blockDiag(node, 'CB_CHILD_COUNT', `:::${node.name} needs at least ${opts.minChildren} children, found ${count}.`));
167
+ }
168
+ if (opts.maxChildren !== undefined && count > opts.maxChildren) {
169
+ report(blockDiag(node, 'CB_CHILD_COUNT', `:::${node.name} allows at most ${opts.maxChildren} children, found ${count}.`));
170
+ }
171
+ return { blocks };
172
+ },
173
+ };
174
+ }
@@ -0,0 +1,23 @@
1
+ export interface SourcePoint {
2
+ /** 1-based */
3
+ line: number;
4
+ /** 1-based */
5
+ column: number;
6
+ /** 0-based char offset into the source string */
7
+ offset: number;
8
+ }
9
+ export interface SourceRange {
10
+ start: SourcePoint;
11
+ end: SourcePoint;
12
+ }
13
+ export type Severity = 'error' | 'warning' | 'info';
14
+ export interface Diagnostic {
15
+ code: string;
16
+ severity: Severity;
17
+ message: string;
18
+ hint?: string;
19
+ blockName?: string;
20
+ position: SourceRange;
21
+ }
22
+ export declare function formatDiagnostic(d: Diagnostic, file?: string): string;
23
+ //# sourceMappingURL=diagnostics.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diagnostics.d.ts","sourceRoot":"","sources":["../src/diagnostics.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,WAAW;IAC1B,cAAc;IACd,IAAI,EAAE,MAAM,CAAA;IACZ,cAAc;IACd,MAAM,EAAE,MAAM,CAAA;IACd,iDAAiD;IACjD,MAAM,EAAE,MAAM,CAAA;CACf;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,WAAW,CAAA;IAClB,GAAG,EAAE,WAAW,CAAA;CACjB;AAED,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,SAAS,GAAG,MAAM,CAAA;AAEnD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAA;IACZ,QAAQ,EAAE,QAAQ,CAAA;IAClB,OAAO,EAAE,MAAM,CAAA;IACf,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,QAAQ,EAAE,WAAW,CAAA;CACtB;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,UAAU,EAAE,IAAI,SAAe,GAAG,MAAM,CAK3E"}
@@ -0,0 +1,7 @@
1
+ export function formatDiagnostic(d, file = 'content.md') {
2
+ const head = `${file}:${d.position.start.line}:${d.position.start.column} ${d.severity} ${d.code}`;
3
+ const lines = [head, d.message];
4
+ if (d.hint)
5
+ lines.push(`hint: ${d.hint}`);
6
+ return lines.join('\n');
7
+ }
@@ -0,0 +1,13 @@
1
+ export { VERSION } from './version.js';
2
+ export type { SourcePoint, SourceRange, Severity, Diagnostic } from './diagnostics.js';
3
+ export { formatDiagnostic } from './diagnostics.js';
4
+ export type { DocumentNode, ContentNode, MarkdownNode, BlockNode } from './ast.js';
5
+ export { parseDocument, type ParseResult } from './parser.js';
6
+ export { parseProps } from './props.js';
7
+ export { bodyLineRange } from './position.js';
8
+ export { createBlockRegistry, defineBlock, BlockRegistry, type BlockDefinition, type ContentModel, type AuthoringMeta, type Report, } from './registry.js';
9
+ export { markdownBody, pipeRows, listItems, childBlocks, type MarkdownBodyData, type PipeRowsData, type ListItemsData, type ListItem, type ChildBlocksData, } from './content-models.js';
10
+ export { validateDocument, isValidatedBlock, type ValidateOptions, type ValidationResult, type ValidatedBlockNode, } from './validate.js';
11
+ export { generateAuthoringGuide, type AuthoringGuideOptions } from './authoring.js';
12
+ export { renderToMarkdown, type MarkdownBlockRenderer, type MarkdownRenderContext, type RenderToMarkdownOptions, } from './render-markdown.js';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +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"}
package/dist/index.js ADDED
@@ -0,0 +1,10 @@
1
+ export { VERSION } from './version.js';
2
+ export { formatDiagnostic } from './diagnostics.js';
3
+ export { parseDocument } from './parser.js';
4
+ export { parseProps } from './props.js';
5
+ export { bodyLineRange } from './position.js';
6
+ export { createBlockRegistry, defineBlock, BlockRegistry, } from './registry.js';
7
+ export { markdownBody, pipeRows, listItems, childBlocks, } from './content-models.js';
8
+ export { validateDocument, isValidatedBlock, } from './validate.js';
9
+ export { generateAuthoringGuide } from './authoring.js';
10
+ export { renderToMarkdown, } from './render-markdown.js';
@@ -0,0 +1,8 @@
1
+ import type { DocumentNode } from './ast.js';
2
+ import type { Diagnostic } from './diagnostics.js';
3
+ export interface ParseResult {
4
+ document: DocumentNode;
5
+ diagnostics: Diagnostic[];
6
+ }
7
+ export declare function parseDocument(source: string): ParseResult;
8
+ //# sourceMappingURL=parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAA0B,YAAY,EAAgB,MAAM,UAAU,CAAA;AAClF,OAAO,KAAK,EAAE,UAAU,EAA4B,MAAM,kBAAkB,CAAA;AAI5E,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,YAAY,CAAA;IACtB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAcD,wBAAgB,aAAa,CAAC,MAAM,EAAE,MAAM,GAAG,WAAW,CAgNzD"}
package/dist/parser.js ADDED
@@ -0,0 +1,200 @@
1
+ import { parseProps } from './props.js';
2
+ const NAME = '[a-z][a-z0-9]*(?:-[a-z0-9]+)*';
3
+ const OPEN_RE = new RegExp(`^(:{3,})(${NAME})(\\{.*)?\\s*$`);
4
+ const CHILD_RE = new RegExp(`^::(${NAME})(\\{.*)?\\s*$`);
5
+ const CLOSE_RE = /^(:{3,})\s*$/;
6
+ const CODE_FENCE_RE = /^(`{3,}|~{3,})/;
7
+ export function parseDocument(source) {
8
+ const diagnostics = [];
9
+ const lines = source.split('\n');
10
+ const lineStart = Array.from({ length: lines.length });
11
+ let off = 0;
12
+ for (let i = 0; i < lines.length; i++) {
13
+ lineStart[i] = off;
14
+ off += lines[i].length + 1;
15
+ }
16
+ const sourceEnd = source.length;
17
+ const point = (line, column, offset) => ({
18
+ line,
19
+ column,
20
+ offset,
21
+ });
22
+ const lineRange = (i) => ({
23
+ start: point(i + 1, 1, lineStart[i]),
24
+ end: point(i + 1, lines[i].length + 1, lineStart[i] + lines[i].length),
25
+ });
26
+ const eofPoint = () => point(lines.length, (lines[lines.length - 1] ?? '').length + 1, sourceEnd);
27
+ const document = {
28
+ type: 'document',
29
+ children: [],
30
+ position: { start: point(1, 1, 0), end: eofPoint() },
31
+ };
32
+ const stack = [];
33
+ let mdStartLine = null;
34
+ let codeFence = null;
35
+ const top = () => stack[stack.length - 1];
36
+ const sink = () => top()?.node.children ?? document.children;
37
+ function flushMarkdown(endLineExclusive) {
38
+ if (mdStartLine === null)
39
+ return;
40
+ const startOff = lineStart[mdStartLine];
41
+ const endOff = endLineExclusive >= lines.length
42
+ ? sourceEnd
43
+ : Math.max(startOff, lineStart[endLineExclusive] - 1);
44
+ const value = source.slice(startOff, endOff);
45
+ if (value.trim() !== '') {
46
+ const node = {
47
+ type: 'markdown',
48
+ value,
49
+ position: {
50
+ start: point(mdStartLine + 1, 1, startOff),
51
+ end: point(endLineExclusive, (lines[endLineExclusive - 1] ?? '').length + 1, endOff),
52
+ },
53
+ };
54
+ sink().push(node);
55
+ }
56
+ mdStartLine = null;
57
+ }
58
+ function openBlock(i, fence, name, rawProps) {
59
+ const openPosition = lineRange(i);
60
+ const parsed = parseProps(rawProps, openPosition);
61
+ for (const d of parsed.diagnostics)
62
+ diagnostics.push({ ...d, blockName: name });
63
+ const node = {
64
+ type: 'block',
65
+ name,
66
+ fence,
67
+ props: parsed.props,
68
+ rawProps,
69
+ children: [],
70
+ body: '',
71
+ position: { start: openPosition.start, end: openPosition.end },
72
+ openPosition,
73
+ closePosition: null,
74
+ };
75
+ sink().push(node);
76
+ stack.push({ node, bodyStart: i + 1 < lines.length ? lineStart[i + 1] : sourceEnd });
77
+ }
78
+ /** bodyEndLine = index of the line that terminates the body (close line, sibling open, or lines.length at EOF). */
79
+ function finalize(frame, bodyEndLine, closeLine) {
80
+ const endOff = bodyEndLine >= lines.length
81
+ ? sourceEnd
82
+ : Math.max(frame.bodyStart, lineStart[bodyEndLine] - 1);
83
+ frame.node.body = source.slice(frame.bodyStart, endOff);
84
+ if (closeLine !== null) {
85
+ frame.node.closePosition = lineRange(closeLine);
86
+ frame.node.position = {
87
+ start: frame.node.openPosition.start,
88
+ end: frame.node.closePosition.end,
89
+ };
90
+ }
91
+ else {
92
+ frame.node.position = {
93
+ start: frame.node.openPosition.start,
94
+ end: bodyEndLine >= lines.length
95
+ ? eofPoint()
96
+ : point(bodyEndLine, (lines[bodyEndLine - 1] ?? '').length + 1, endOff),
97
+ };
98
+ }
99
+ }
100
+ function popChildIfOpen(i) {
101
+ if (top()?.node.fence === 2) {
102
+ flushMarkdown(i);
103
+ finalize(stack.pop(), i, null);
104
+ }
105
+ }
106
+ for (let i = 0; i < lines.length; i++) {
107
+ const trimmed = lines[i].trim();
108
+ // Code fences are opaque: nothing inside them opens or closes blocks.
109
+ const fenceMatch = trimmed.match(CODE_FENCE_RE);
110
+ if (codeFence !== null) {
111
+ if (fenceMatch &&
112
+ fenceMatch[1][0] === codeFence[0] &&
113
+ fenceMatch[1].length >= codeFence.length) {
114
+ codeFence = null;
115
+ }
116
+ if (mdStartLine === null)
117
+ mdStartLine = i;
118
+ continue;
119
+ }
120
+ if (fenceMatch) {
121
+ codeFence = fenceMatch[1];
122
+ if (mdStartLine === null)
123
+ mdStartLine = i;
124
+ continue;
125
+ }
126
+ const close = trimmed.match(CLOSE_RE);
127
+ if (close) {
128
+ popChildIfOpen(i);
129
+ flushMarkdown(i);
130
+ const frame = top();
131
+ if (!frame) {
132
+ diagnostics.push({
133
+ code: 'CB_UNMATCHED_CLOSE',
134
+ severity: 'warning',
135
+ message: `Closing fence "${close[1]}" has no open block.`,
136
+ position: lineRange(i),
137
+ });
138
+ mdStartLine = i; // keep it as literal markdown
139
+ continue;
140
+ }
141
+ if (close[1].length !== frame.node.fence) {
142
+ diagnostics.push({
143
+ code: 'CB_FENCE_MISMATCH',
144
+ severity: 'warning',
145
+ message: `Closing fence has ${close[1].length} colons but ":::${frame.node.name}" opened with ${frame.node.fence}.`,
146
+ hint: 'Match the opening fence length, or use a longer fence on the outer block to disambiguate nesting.',
147
+ blockName: frame.node.name,
148
+ position: lineRange(i),
149
+ });
150
+ }
151
+ stack.pop();
152
+ finalize(frame, i, i);
153
+ continue;
154
+ }
155
+ const open = trimmed.match(OPEN_RE);
156
+ if (open) {
157
+ flushMarkdown(i);
158
+ openBlock(i, open[1].length, open[2], open[3] ?? null);
159
+ continue;
160
+ }
161
+ const child = trimmed.match(CHILD_RE);
162
+ if (child) {
163
+ if (stack.length === 0) {
164
+ diagnostics.push({
165
+ code: 'CB_CHILD_OUTSIDE_BLOCK',
166
+ severity: 'warning',
167
+ message: `Child block "::${child[1]}" used outside of a container block; treated as plain text.`,
168
+ blockName: child[1],
169
+ position: lineRange(i),
170
+ });
171
+ if (mdStartLine === null)
172
+ mdStartLine = i;
173
+ continue;
174
+ }
175
+ popChildIfOpen(i);
176
+ flushMarkdown(i);
177
+ openBlock(i, 2, child[1], child[2] ?? null);
178
+ continue;
179
+ }
180
+ if (mdStartLine === null)
181
+ mdStartLine = i;
182
+ }
183
+ // EOF: close children implicitly, report unclosed containers.
184
+ flushMarkdown(lines.length);
185
+ while (stack.length > 0) {
186
+ const frame = stack.pop();
187
+ finalize(frame, lines.length, null);
188
+ if (frame.node.fence >= 3) {
189
+ diagnostics.push({
190
+ code: 'CB_UNCLOSED_BLOCK',
191
+ severity: 'error',
192
+ message: `Block ":::${frame.node.name}" is never closed.`,
193
+ hint: `Add a line containing only "${':'.repeat(frame.node.fence)}" after the block body.`,
194
+ blockName: frame.node.name,
195
+ position: frame.node.openPosition,
196
+ });
197
+ }
198
+ }
199
+ return { document, diagnostics };
200
+ }
@@ -0,0 +1,8 @@
1
+ import type { BlockNode } from './ast.js';
2
+ import type { SourceRange } from './diagnostics.js';
3
+ /**
4
+ * Document-level range of the Nth line (0-based) of a block's body.
5
+ * Content models use this so row/item diagnostics point at the exact line.
6
+ */
7
+ export declare function bodyLineRange(node: BlockNode, bodyLineIndex: number): SourceRange;
8
+ //# sourceMappingURL=position.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"position.d.ts","sourceRoot":"","sources":["../src/position.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAEnD;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,EAAE,aAAa,EAAE,MAAM,GAAG,WAAW,CAUjF"}
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Document-level range of the Nth line (0-based) of a block's body.
3
+ * Content models use this so row/item diagnostics point at the exact line.
4
+ */
5
+ export function bodyLineRange(node, bodyLineIndex) {
6
+ const bodyLines = node.body.split('\n');
7
+ const text = bodyLines[bodyLineIndex] ?? '';
8
+ let offset = node.openPosition.end.offset + 1; // skip the newline after the open line
9
+ for (let i = 0; i < bodyLineIndex; i++)
10
+ offset += bodyLines[i].length + 1;
11
+ const line = node.openPosition.start.line + 1 + bodyLineIndex;
12
+ return {
13
+ start: { line, column: 1, offset },
14
+ end: { line, column: text.length + 1, offset: offset + text.length },
15
+ };
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { Diagnostic, SourceRange } from './diagnostics.js';
2
+ export interface ParsedProps {
3
+ props: Record<string, unknown>;
4
+ diagnostics: Diagnostic[];
5
+ }
6
+ /**
7
+ * Parses the `{...}` props segment of a block open line.
8
+ * Grammar: key="quoted" | key=123 | key=true | key=bare-ident | key (flag = true).
9
+ * No expressions, arrays, or objects — by design (spec: Content Syntax / Props).
10
+ */
11
+ export declare function parseProps(raw: string | null, position: SourceRange): ParsedProps;
12
+ //# sourceMappingURL=props.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"props.d.ts","sourceRoot":"","sources":["../src/props.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAA;AAM/D,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;IAC9B,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED;;;;GAIG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI,EAAE,QAAQ,EAAE,WAAW,GAAG,WAAW,CAyFjF"}
package/dist/props.js ADDED
@@ -0,0 +1,98 @@
1
+ const KEY_RE = /^[a-zA-Z][a-zA-Z0-9_-]*/;
2
+ const NUMBER_RE = /^-?\d+(\.\d+)?$/;
3
+ const IDENT_RE = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
4
+ /**
5
+ * Parses the `{...}` props segment of a block open line.
6
+ * Grammar: key="quoted" | key=123 | key=true | key=bare-ident | key (flag = true).
7
+ * No expressions, arrays, or objects — by design (spec: Content Syntax / Props).
8
+ */
9
+ export function parseProps(raw, position) {
10
+ const props = {};
11
+ const diagnostics = [];
12
+ if (raw === null)
13
+ return { props, diagnostics };
14
+ const err = (message) => diagnostics.push({
15
+ code: 'CB_PROPS_SYNTAX',
16
+ severity: 'error',
17
+ message,
18
+ hint: 'Props accept quoted strings, numbers, booleans, and bare identifiers only.',
19
+ position,
20
+ });
21
+ if (!raw.endsWith('}')) {
22
+ return {
23
+ props: {},
24
+ diagnostics: [
25
+ {
26
+ code: 'CB_PROPS_SYNTAX',
27
+ severity: 'error',
28
+ message: 'Props are missing the closing "}".',
29
+ hint: 'Props accept quoted strings, numbers, booleans, and bare identifiers only.',
30
+ position,
31
+ },
32
+ ],
33
+ };
34
+ }
35
+ const inner = raw.slice(1, -1);
36
+ let i = 0;
37
+ while (i < inner.length) {
38
+ while (i < inner.length && /\s/.test(inner[i]))
39
+ i++;
40
+ if (i >= inner.length)
41
+ break;
42
+ const keyMatch = inner.slice(i).match(KEY_RE);
43
+ if (!keyMatch) {
44
+ err(`Unexpected character "${inner[i]}" in props.`);
45
+ return { props, diagnostics };
46
+ }
47
+ const key = keyMatch[0];
48
+ i += key.length;
49
+ if (inner[i] !== '=') {
50
+ props[key] = true; // flag shorthand
51
+ continue;
52
+ }
53
+ i++;
54
+ if (inner[i] === '"') {
55
+ i++;
56
+ let value = '';
57
+ let closed = false;
58
+ while (i < inner.length) {
59
+ if (inner[i] === '\\' && inner[i + 1] === '"') {
60
+ value += '"';
61
+ i += 2;
62
+ continue;
63
+ }
64
+ if (inner[i] === '"') {
65
+ closed = true;
66
+ i++;
67
+ break;
68
+ }
69
+ value += inner[i];
70
+ i++;
71
+ }
72
+ if (!closed) {
73
+ err(`Unterminated string for prop "${key}".`);
74
+ return { props, diagnostics };
75
+ }
76
+ props[key] = value;
77
+ continue;
78
+ }
79
+ const valMatch = inner.slice(i).match(/^[^\s]+/);
80
+ if (!valMatch) {
81
+ err(`Missing value for prop "${key}".`);
82
+ return { props, diagnostics };
83
+ }
84
+ const rawVal = valMatch[0];
85
+ i += rawVal.length;
86
+ if (rawVal === 'true')
87
+ props[key] = true;
88
+ else if (rawVal === 'false')
89
+ props[key] = false;
90
+ else if (NUMBER_RE.test(rawVal))
91
+ props[key] = Number(rawVal);
92
+ else if (IDENT_RE.test(rawVal))
93
+ props[key] = rawVal;
94
+ else
95
+ err(`Invalid value "${rawVal}" for prop "${key}".`);
96
+ }
97
+ return { props, diagnostics };
98
+ }
@@ -0,0 +1,42 @@
1
+ import type { ZodType } from 'zod';
2
+ import type { BlockNode } from './ast.js';
3
+ import type { Diagnostic } from './diagnostics.js';
4
+ import { type AuthoringGuideOptions } from './authoring.js';
5
+ export type Report = (d: Diagnostic) => void;
6
+ /** How a block's body is parsed and validated. Created via content-model helpers. */
7
+ export interface ContentModel<TData = unknown> {
8
+ kind: string;
9
+ /** One-line shape description used in generated authoring guides. */
10
+ describe(): string;
11
+ /** Parse the body; report() diagnostics with positions; always return best-effort data. */
12
+ parse(node: BlockNode, report: Report): TData;
13
+ }
14
+ export interface AuthoringMeta {
15
+ useWhen: string[];
16
+ avoidWhen: string[];
17
+ example: string;
18
+ }
19
+ export interface BlockDefinition<TData = unknown> {
20
+ name: string;
21
+ description: string;
22
+ /** zod schema for the open-line props. Omit for prop-less blocks. */
23
+ props?: ZodType;
24
+ content: ContentModel<TData>;
25
+ /** Only valid nested inside a parent that allows it (e.g. `tab` inside `tabs`). */
26
+ childOnly?: boolean;
27
+ /** Hint for renderers: needs client-side behavior (e.g. tabs). */
28
+ interactive?: boolean;
29
+ version?: string;
30
+ authoring: AuthoringMeta;
31
+ }
32
+ export declare function defineBlock<TData>(def: BlockDefinition<TData>): BlockDefinition<TData>;
33
+ export declare class BlockRegistry {
34
+ private defs;
35
+ use(pack: ReadonlyArray<BlockDefinition<never>> | ReadonlyArray<BlockDefinition<unknown>>): this;
36
+ add(def: BlockDefinition<never> | BlockDefinition<unknown>): this;
37
+ get(name: string): BlockDefinition<unknown> | undefined;
38
+ all(): BlockDefinition<unknown>[];
39
+ toAuthoringGuide(opts?: AuthoringGuideOptions): string;
40
+ }
41
+ export declare function createBlockRegistry(): BlockRegistry;
42
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,KAAK,CAAA;AAElC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,UAAU,CAAA;AACzC,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAA;AAElD,OAAO,EAA0B,KAAK,qBAAqB,EAAE,MAAM,gBAAgB,CAAA;AAEnF,MAAM,MAAM,MAAM,GAAG,CAAC,CAAC,EAAE,UAAU,KAAK,IAAI,CAAA;AAE5C,qFAAqF;AACrF,MAAM,WAAW,YAAY,CAAC,KAAK,GAAG,OAAO;IAC3C,IAAI,EAAE,MAAM,CAAA;IACZ,qEAAqE;IACrE,QAAQ,IAAI,MAAM,CAAA;IAClB,2FAA2F;IAC3F,KAAK,CAAC,IAAI,EAAE,SAAS,EAAE,MAAM,EAAE,MAAM,GAAG,KAAK,CAAA;CAC9C;AAED,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,EAAE,CAAA;IACjB,SAAS,EAAE,MAAM,EAAE,CAAA;IACnB,OAAO,EAAE,MAAM,CAAA;CAChB;AAED,MAAM,WAAW,eAAe,CAAC,KAAK,GAAG,OAAO;IAC9C,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,EAAE,MAAM,CAAA;IACnB,qEAAqE;IACrE,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,OAAO,EAAE,YAAY,CAAC,KAAK,CAAC,CAAA;IAC5B,mFAAmF;IACnF,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,kEAAkE;IAClE,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,SAAS,EAAE,aAAa,CAAA;CACzB;AAID,wBAAgB,WAAW,CAAC,KAAK,EAAE,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC,GAAG,eAAe,CAAC,KAAK,CAAC,CAKtF;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,IAAI,CAA8C;IAE1D,GAAG,CAAC,IAAI,EAAE,aAAa,CAAC,eAAe,CAAC,KAAK,CAAC,CAAC,GAAG,aAAa,CAAC,eAAe,CAAC,OAAO,CAAC,CAAC,GAAG,IAAI;IAKhG,GAAG,CAAC,GAAG,EAAE,eAAe,CAAC,KAAK,CAAC,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,IAAI;IAWjE,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,eAAe,CAAC,OAAO,CAAC,GAAG,SAAS;IAIvD,GAAG,IAAI,eAAe,CAAC,OAAO,CAAC,EAAE;IAIjC,gBAAgB,CAAC,IAAI,GAAE,qBAA0B,GAAG,MAAM;CAG3D;AAED,wBAAgB,mBAAmB,IAAI,aAAa,CAEnD"}
@@ -0,0 +1,36 @@
1
+ import { generateAuthoringGuide } from './authoring.js';
2
+ const KEBAB_RE = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
3
+ export function defineBlock(def) {
4
+ if (!KEBAB_RE.test(def.name)) {
5
+ throw new Error(`Block name "${def.name}" must be lowercase kebab-case.`);
6
+ }
7
+ return def;
8
+ }
9
+ export class BlockRegistry {
10
+ defs = new Map();
11
+ use(pack) {
12
+ for (const def of pack)
13
+ this.add(def);
14
+ return this;
15
+ }
16
+ add(def) {
17
+ const d = def;
18
+ if (this.defs.has(d.name)) {
19
+ throw new Error(`Duplicate block "${d.name}". Namespace it (e.g. "acme-${d.name}") or remove the duplicate.`);
20
+ }
21
+ this.defs.set(d.name, d);
22
+ return this;
23
+ }
24
+ get(name) {
25
+ return this.defs.get(name);
26
+ }
27
+ all() {
28
+ return [...this.defs.values()];
29
+ }
30
+ toAuthoringGuide(opts = {}) {
31
+ return generateAuthoringGuide(this.all(), opts);
32
+ }
33
+ }
34
+ export function createBlockRegistry() {
35
+ return new BlockRegistry();
36
+ }
@@ -0,0 +1,16 @@
1
+ import type { ContentNode, DocumentNode } from './ast.js';
2
+ import { type ValidatedBlockNode } from './validate.js';
3
+ export interface MarkdownRenderContext {
4
+ renderNodes(nodes: ContentNode[]): string;
5
+ }
6
+ export type MarkdownBlockRenderer = (node: ValidatedBlockNode<unknown>, ctx: MarkdownRenderContext) => string;
7
+ export interface RenderToMarkdownOptions {
8
+ renderers?: Record<string, MarkdownBlockRenderer>;
9
+ }
10
+ /**
11
+ * Plain-Markdown fallback: preserves prose verbatim, converts blocks via the
12
+ * supplied renderers, and degrades unrenderable blocks to their raw body so
13
+ * no information is lost (spec: Markdown fallback renderer).
14
+ */
15
+ export declare function renderToMarkdown(document: DocumentNode, opts?: RenderToMarkdownOptions): string;
16
+ //# sourceMappingURL=render-markdown.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render-markdown.d.ts","sourceRoot":"","sources":["../src/render-markdown.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AAEzD,OAAO,EAAoB,KAAK,kBAAkB,EAAE,MAAM,eAAe,CAAA;AAEzE,MAAM,WAAW,qBAAqB;IACpC,WAAW,CAAC,KAAK,EAAE,WAAW,EAAE,GAAG,MAAM,CAAA;CAC1C;AAED,MAAM,MAAM,qBAAqB,GAAG,CAClC,IAAI,EAAE,kBAAkB,CAAC,OAAO,CAAC,EACjC,GAAG,EAAE,qBAAqB,KACvB,MAAM,CAAA;AAEX,MAAM,WAAW,uBAAuB;IACtC,SAAS,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,qBAAqB,CAAC,CAAA;CAClD;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAC9B,QAAQ,EAAE,YAAY,EACtB,IAAI,GAAE,uBAA4B,GACjC,MAAM,CAeR"}
@@ -0,0 +1,24 @@
1
+ import { isValidatedBlock } from './validate.js';
2
+ /**
3
+ * Plain-Markdown fallback: preserves prose verbatim, converts blocks via the
4
+ * supplied renderers, and degrades unrenderable blocks to their raw body so
5
+ * no information is lost (spec: Markdown fallback renderer).
6
+ */
7
+ export function renderToMarkdown(document, opts = {}) {
8
+ const ctx = {
9
+ renderNodes(nodes) {
10
+ return nodes
11
+ .map((node) => {
12
+ if (node.type === 'markdown')
13
+ return node.value.trim();
14
+ const renderer = opts.renderers?.[node.name];
15
+ if (renderer && isValidatedBlock(node))
16
+ return renderer(node, ctx).trim();
17
+ return node.body.trim();
18
+ })
19
+ .filter((s) => s !== '')
20
+ .join('\n\n');
21
+ },
22
+ };
23
+ return ctx.renderNodes(document.children) + '\n';
24
+ }
@@ -0,0 +1,24 @@
1
+ import type { BlockNode, ContentNode, DocumentNode } from './ast.js';
2
+ import type { Diagnostic } from './diagnostics.js';
3
+ import type { ParseResult } from './parser.js';
4
+ import type { BlockDefinition, BlockRegistry } from './registry.js';
5
+ export interface ValidateOptions {
6
+ /** Severity of unknown block names. Default "error". */
7
+ unknownBlocks?: 'error' | 'warning';
8
+ /** Max block nesting depth. Default 4. */
9
+ maxDepth?: number;
10
+ /** URL protocols allowed in markdown links. Default http/https/mailto. */
11
+ allowedProtocols?: string[];
12
+ }
13
+ export interface ValidationResult {
14
+ ok: boolean;
15
+ document: DocumentNode;
16
+ diagnostics: Diagnostic[];
17
+ }
18
+ export type ValidatedBlockNode<TData = unknown> = BlockNode & {
19
+ data: TData;
20
+ definition: BlockDefinition<TData>;
21
+ };
22
+ export declare function isValidatedBlock(node: ContentNode): node is ValidatedBlockNode;
23
+ export declare function validateDocument(parsed: ParseResult, registry: BlockRegistry, options?: ValidateOptions): ValidationResult;
24
+ //# sourceMappingURL=validate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate.d.ts","sourceRoot":"","sources":["../src/validate.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA;AACpE,OAAO,KAAK,EAAE,UAAU,EAAe,MAAM,kBAAkB,CAAA;AAC/D,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,aAAa,CAAA;AAC9C,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,MAAM,eAAe,CAAA;AAEnE,MAAM,WAAW,eAAe;IAC9B,wDAAwD;IACxD,aAAa,CAAC,EAAE,OAAO,GAAG,SAAS,CAAA;IACnC,0CAA0C;IAC1C,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,MAAM,EAAE,CAAA;CAC5B;AAED,MAAM,WAAW,gBAAgB;IAC/B,EAAE,EAAE,OAAO,CAAA;IACX,QAAQ,EAAE,YAAY,CAAA;IACtB,WAAW,EAAE,UAAU,EAAE,CAAA;CAC1B;AAED,MAAM,MAAM,kBAAkB,CAAC,KAAK,GAAG,OAAO,IAAI,SAAS,GAAG;IAC5D,IAAI,EAAE,KAAK,CAAA;IACX,UAAU,EAAE,eAAe,CAAC,KAAK,CAAC,CAAA;CACnC,CAAA;AAED,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,IAAI,kBAAkB,CAE9E;AA4BD,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,WAAW,EACnB,QAAQ,EAAE,aAAa,EACvB,OAAO,GAAE,eAAoB,GAC5B,gBAAgB,CA4FlB"}
@@ -0,0 +1,114 @@
1
+ export function isValidatedBlock(node) {
2
+ return node.type === 'block' && 'data' in node && 'definition' in node;
3
+ }
4
+ const LINK_URL_RE = /\]\(([^)\s]+)/g;
5
+ const HAS_SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
6
+ function checkUrls(value, position, allowed, report) {
7
+ for (const match of value.matchAll(LINK_URL_RE)) {
8
+ const url = match[1];
9
+ const scheme = url.match(HAS_SCHEME_RE);
10
+ if (!scheme)
11
+ continue; // relative URLs are fine
12
+ const protocol = `${scheme[0].slice(0, -1).toLowerCase()}:`;
13
+ if (!allowed.includes(protocol)) {
14
+ report({
15
+ code: 'CB_URL_PROTOCOL',
16
+ severity: 'error',
17
+ message: `URL protocol "${protocol}" is not allowed.`,
18
+ hint: `Allowed: ${allowed.join(', ')}. Configure via validateDocument options.allowedProtocols.`,
19
+ position,
20
+ });
21
+ }
22
+ }
23
+ }
24
+ export function validateDocument(parsed, registry, options = {}) {
25
+ const opts = {
26
+ unknownBlocks: options.unknownBlocks ?? 'error',
27
+ maxDepth: options.maxDepth ?? 4,
28
+ allowedProtocols: options.allowedProtocols ?? ['http:', 'https:', 'mailto:'],
29
+ };
30
+ const diagnostics = [...parsed.diagnostics];
31
+ const report = (d) => diagnostics.push(d);
32
+ function walk(nodes, parent, depth) {
33
+ for (const node of nodes) {
34
+ if (node.type === 'markdown') {
35
+ checkUrls(node.value, node.position, opts.allowedProtocols, report);
36
+ continue;
37
+ }
38
+ if (depth > opts.maxDepth) {
39
+ report({
40
+ code: 'CB_NESTING_DEPTH',
41
+ severity: 'error',
42
+ message: `Block "${node.name}" exceeds the maximum nesting depth of ${opts.maxDepth}.`,
43
+ blockName: node.name,
44
+ position: node.openPosition,
45
+ });
46
+ continue;
47
+ }
48
+ const def = registry.get(node.name);
49
+ if (!def) {
50
+ report({
51
+ code: 'CB_UNKNOWN_BLOCK',
52
+ severity: opts.unknownBlocks,
53
+ message: `Unknown block "${node.name}".`,
54
+ hint: `Known blocks: ${registry
55
+ .all()
56
+ .map((d) => d.name)
57
+ .join(', ')}.`,
58
+ blockName: node.name,
59
+ position: node.openPosition,
60
+ });
61
+ continue;
62
+ }
63
+ let valid = true;
64
+ if (def.childOnly && parent === null) {
65
+ valid = false;
66
+ report({
67
+ code: 'CB_CHILD_ONLY',
68
+ severity: 'error',
69
+ message: `"${node.name}" is a child block and cannot be used at the top level.`,
70
+ blockName: node.name,
71
+ position: node.openPosition,
72
+ });
73
+ }
74
+ if (def.props) {
75
+ const result = def.props.safeParse(node.props);
76
+ if (result.success) {
77
+ node.props = result.data;
78
+ }
79
+ else {
80
+ valid = false;
81
+ for (const issue of result.error.issues) {
82
+ report({
83
+ code: 'CB_PROPS_INVALID',
84
+ severity: 'error',
85
+ message: `${node.name}: prop "${issue.path.join('.') || '(root)'}" ${issue.message}`,
86
+ blockName: node.name,
87
+ position: node.openPosition,
88
+ });
89
+ }
90
+ }
91
+ }
92
+ const before = diagnostics.length;
93
+ const data = def.content.parse(node, report);
94
+ let contentError = false;
95
+ for (let j = before; j < diagnostics.length; j++) {
96
+ if (diagnostics[j].severity === 'error') {
97
+ contentError = true;
98
+ break;
99
+ }
100
+ }
101
+ if (contentError)
102
+ valid = false;
103
+ if (valid) {
104
+ ;
105
+ node.data = data;
106
+ node.definition = def;
107
+ }
108
+ walk(node.children, node, depth + 1);
109
+ }
110
+ }
111
+ walk(parsed.document.children, null, 1);
112
+ const ok = !diagnostics.some((d) => d.severity === 'error');
113
+ return { ok, document: parsed.document, diagnostics };
114
+ }
@@ -0,0 +1,2 @@
1
+ export declare const VERSION = "0.1.0";
2
+ //# sourceMappingURL=version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"version.d.ts","sourceRoot":"","sources":["../src/version.ts"],"names":[],"mappings":"AAAA,eAAO,MAAM,OAAO,UAAU,CAAA"}
@@ -0,0 +1 @@
1
+ export const VERSION = '0.1.0';
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "@contentbit/core",
3
+ "version": "0.1.0",
4
+ "description": "Parser, validator, and registry for Content Blocks: structured Markdown components without framework lock-in.",
5
+ "keywords": [
6
+ "ast",
7
+ "content",
8
+ "directives",
9
+ "llm",
10
+ "markdown",
11
+ "validation"
12
+ ],
13
+ "homepage": "https://contentbit.dev",
14
+ "bugs": "https://github.com/agonist/contentbit/issues",
15
+ "license": "MIT",
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/agonist/contentbit.git",
19
+ "directory": "packages/core"
20
+ },
21
+ "files": [
22
+ "dist"
23
+ ],
24
+ "type": "module",
25
+ "sideEffects": false,
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.js"
30
+ }
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "zod": "^4.0.0"
37
+ },
38
+ "devDependencies": {
39
+ "typescript": "^5.9.3",
40
+ "vitest": "^4.0.17"
41
+ },
42
+ "scripts": {
43
+ "build": "tsc -p tsconfig.build.json",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest"
46
+ }
47
+ }