@notrealstudio/nr-md 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 +52 -0
- package/README.md +136 -0
- package/dist/coerce.d.ts +30 -0
- package/dist/coerce.js +159 -0
- package/dist/includes.d.ts +26 -0
- package/dist/includes.js +218 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +23 -0
- package/dist/interpolation.d.ts +41 -0
- package/dist/interpolation.js +135 -0
- package/dist/json5-value.d.ts +30 -0
- package/dist/json5-value.js +79 -0
- package/dist/parser.d.ts +3 -0
- package/dist/parser.js +227 -0
- package/dist/schema.d.ts +80 -0
- package/dist/schema.js +853 -0
- package/dist/serialize.d.ts +83 -0
- package/dist/serialize.js +525 -0
- package/dist/typed-header.d.ts +56 -0
- package/dist/typed-header.js +229 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.js +4 -0
- package/dist/value.d.ts +33 -0
- package/dist/value.js +193 -0
- package/package.json +68 -0
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { InterpolationSpan } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Given that `${` opens just before `fromAfterOpen`, return the offset of the
|
|
4
|
+
* matching `}` in `s`, or -1 if it is unbalanced.
|
|
5
|
+
*
|
|
6
|
+
* Brace-balanced and aware of:
|
|
7
|
+
* - escape sequences `\X` (consume both characters)
|
|
8
|
+
* - nested `${...}` (increment depth; a bare `{` is NOT counted — §7.1 has no
|
|
9
|
+
* bare braces outside string literals)
|
|
10
|
+
* - double-quoted strings (skip the content; honour `\"`)
|
|
11
|
+
*/
|
|
12
|
+
export declare function findMatchingBrace(s: string, fromAfterOpen: number): number;
|
|
13
|
+
/**
|
|
14
|
+
* Scan a value or body string for `${...}` and return their spans, in source
|
|
15
|
+
* order.
|
|
16
|
+
*
|
|
17
|
+
* - `\${` is a literal `${` and is NOT an interpolation.
|
|
18
|
+
* - An unbalanced `${` is silently treated as literal text.
|
|
19
|
+
*
|
|
20
|
+
* NB: quotes are NOT skipped here. `${}` inside `"..."` in arbitrary text (JSON,
|
|
21
|
+
* prose) is a live interpolation; quotes are grammar only in a whole-value
|
|
22
|
+
* scalar literal (coerce.ts), not in this scan.
|
|
23
|
+
*/
|
|
24
|
+
export declare function scanInterpolations(s: string): InterpolationSpan[];
|
|
25
|
+
/**
|
|
26
|
+
* Detect a backslash-escaped interpolation opener — the opt-out marker (§2.5).
|
|
27
|
+
*
|
|
28
|
+
* An escaped opener means "do NOT resolve this". The marker MUST survive parsing
|
|
29
|
+
* intact: unescaping it eagerly into a bare `${` would turn the value into an
|
|
30
|
+
* ordinary interpolation, and a resolver would substitute it the moment the
|
|
31
|
+
* value flowed through recursion — silently breaking opt-out (format-spec
|
|
32
|
+
* open #1: in values and results, not only in source text).
|
|
33
|
+
*
|
|
34
|
+
* So any value carrying the marker is stored as an InterpolatedValue with `raw`
|
|
35
|
+
* preserved, escapes NOT unfolded.
|
|
36
|
+
*
|
|
37
|
+
* Char codes (no string literals) keep this robust across edit transports. The
|
|
38
|
+
* walk mirrors scanInterpolations' escape handling, so an escaped backslash
|
|
39
|
+
* followed by a LIVE interpolation is not a false positive.
|
|
40
|
+
*/
|
|
41
|
+
export declare function hasOptOutEscape(s: string): boolean;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Interpolation lexing (§7) — brace-balanced, no regex, internal to nr-md.
|
|
2
|
+
//
|
|
3
|
+
// The split this file draws: nr-md answers "does this value interpolate, and
|
|
4
|
+
// where", never "what does the expression mean". Recognising `${...}` is
|
|
5
|
+
// lexical — you need it to tokenise attribute values at all, since a comma or a
|
|
6
|
+
// closing bracket inside `${}` is not a delimiter. Reading the expression is a
|
|
7
|
+
// different language on top, and it belongs to whoever evaluates it.
|
|
8
|
+
//
|
|
9
|
+
// So: spans out, no expression tree, no evaluation vocabulary in the AST.
|
|
10
|
+
/**
|
|
11
|
+
* Given that `${` opens just before `fromAfterOpen`, return the offset of the
|
|
12
|
+
* matching `}` in `s`, or -1 if it is unbalanced.
|
|
13
|
+
*
|
|
14
|
+
* Brace-balanced and aware of:
|
|
15
|
+
* - escape sequences `\X` (consume both characters)
|
|
16
|
+
* - nested `${...}` (increment depth; a bare `{` is NOT counted — §7.1 has no
|
|
17
|
+
* bare braces outside string literals)
|
|
18
|
+
* - double-quoted strings (skip the content; honour `\"`)
|
|
19
|
+
*/
|
|
20
|
+
export function findMatchingBrace(s, fromAfterOpen) {
|
|
21
|
+
let depth = 1;
|
|
22
|
+
let i = fromAfterOpen;
|
|
23
|
+
while (i < s.length) {
|
|
24
|
+
const c = s[i];
|
|
25
|
+
if (c === '\\' && i + 1 < s.length) {
|
|
26
|
+
i += 2;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (c === '"') {
|
|
30
|
+
i++;
|
|
31
|
+
while (i < s.length) {
|
|
32
|
+
if (s[i] === '\\' && i + 1 < s.length) {
|
|
33
|
+
i += 2;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (s[i] === '"') {
|
|
37
|
+
i++;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (c === '$' && i + 1 < s.length && s[i + 1] === '{') {
|
|
45
|
+
depth++;
|
|
46
|
+
i += 2;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (c === '}') {
|
|
50
|
+
depth--;
|
|
51
|
+
if (depth === 0)
|
|
52
|
+
return i;
|
|
53
|
+
i++;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
i++;
|
|
57
|
+
}
|
|
58
|
+
return -1;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Scan a value or body string for `${...}` and return their spans, in source
|
|
62
|
+
* order.
|
|
63
|
+
*
|
|
64
|
+
* - `\${` is a literal `${` and is NOT an interpolation.
|
|
65
|
+
* - An unbalanced `${` is silently treated as literal text.
|
|
66
|
+
*
|
|
67
|
+
* NB: quotes are NOT skipped here. `${}` inside `"..."` in arbitrary text (JSON,
|
|
68
|
+
* prose) is a live interpolation; quotes are grammar only in a whole-value
|
|
69
|
+
* scalar literal (coerce.ts), not in this scan.
|
|
70
|
+
*/
|
|
71
|
+
export function scanInterpolations(s) {
|
|
72
|
+
const out = [];
|
|
73
|
+
let i = 0;
|
|
74
|
+
while (i < s.length) {
|
|
75
|
+
const c = s[i];
|
|
76
|
+
if (c === '\\' && s[i + 1] === '$' && s[i + 2] === '{') {
|
|
77
|
+
// literal ${
|
|
78
|
+
i += 3;
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (c === '\\' && i + 1 < s.length) {
|
|
82
|
+
i += 2;
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (c === '$' && s[i + 1] === '{') {
|
|
86
|
+
const start = i;
|
|
87
|
+
const end = findMatchingBrace(s, i + 2);
|
|
88
|
+
if (end === -1) {
|
|
89
|
+
// Unbalanced — literal text; step past the `$`.
|
|
90
|
+
i++;
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
out.push({ raw: s.slice(i + 2, end), start, end: end + 1 });
|
|
94
|
+
i = end + 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Detect a backslash-escaped interpolation opener — the opt-out marker (§2.5).
|
|
103
|
+
*
|
|
104
|
+
* An escaped opener means "do NOT resolve this". The marker MUST survive parsing
|
|
105
|
+
* intact: unescaping it eagerly into a bare `${` would turn the value into an
|
|
106
|
+
* ordinary interpolation, and a resolver would substitute it the moment the
|
|
107
|
+
* value flowed through recursion — silently breaking opt-out (format-spec
|
|
108
|
+
* open #1: in values and results, not only in source text).
|
|
109
|
+
*
|
|
110
|
+
* So any value carrying the marker is stored as an InterpolatedValue with `raw`
|
|
111
|
+
* preserved, escapes NOT unfolded.
|
|
112
|
+
*
|
|
113
|
+
* Char codes (no string literals) keep this robust across edit transports. The
|
|
114
|
+
* walk mirrors scanInterpolations' escape handling, so an escaped backslash
|
|
115
|
+
* followed by a LIVE interpolation is not a false positive.
|
|
116
|
+
*/
|
|
117
|
+
export function hasOptOutEscape(s) {
|
|
118
|
+
const BACKSLASH = 92;
|
|
119
|
+
const DOLLAR = 36;
|
|
120
|
+
const BRACE = 123;
|
|
121
|
+
let i = 0;
|
|
122
|
+
while (i < s.length) {
|
|
123
|
+
if (s.charCodeAt(i) === BACKSLASH &&
|
|
124
|
+
s.charCodeAt(i + 1) === DOLLAR &&
|
|
125
|
+
s.charCodeAt(i + 2) === BRACE) {
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
if (s.charCodeAt(i) === BACKSLASH && i + 1 < s.length) {
|
|
129
|
+
i += 2;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
i++;
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Json5Object } from './types.js';
|
|
2
|
+
/** A malformed JSON5 attribute value (same convention as TableParseError, §5). */
|
|
3
|
+
export declare class Json5ParseError extends Error {
|
|
4
|
+
/** 1-based line in the source document, when known. */
|
|
5
|
+
readonly line?: number;
|
|
6
|
+
/** Attribute key the value belongs to, when known. */
|
|
7
|
+
readonly key?: string;
|
|
8
|
+
constructor(message: string, line?: number, key?: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The shape of a JSON5 object (§3): the value opens with `{` and closes with `}`.
|
|
12
|
+
*
|
|
13
|
+
* Test the RAW value, before unescape. Otherwise `\{literal}` would unfold to
|
|
14
|
+
* `{literal}` first, get caught by the detection, and the escape (§2.5) would
|
|
15
|
+
* stop working.
|
|
16
|
+
*
|
|
17
|
+
* `{name} says hi` opens with `{` but does not close with `}`, so it is not the
|
|
18
|
+
* shape — just prose. Prose and this detection do not collide.
|
|
19
|
+
*/
|
|
20
|
+
export declare function isJson5Shaped(raw: string): boolean;
|
|
21
|
+
/** Flat guard telling a JSON5 object from an InterpolatedValue (`{raw, placeholders}`). */
|
|
22
|
+
export declare function isJson5Object(v: unknown): v is Json5Object;
|
|
23
|
+
/**
|
|
24
|
+
* Strict parse of a value that passed {@link isJson5Shaped} (§3).
|
|
25
|
+
*
|
|
26
|
+
* Single-line is a consequence of position, not a rule: an attribute value does
|
|
27
|
+
* not wrap. A multi-line result substituted into a JSON5 string is invalid and
|
|
28
|
+
* throws, deliberately — multi-line structure is what blocks are for.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseJson5Object(raw: string, line?: number, key?: string): Json5Object;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// JSON5 object as an attribute value (format-spec §3; json5-scalar-spec).
|
|
2
|
+
// Detection is by shape and it is strict: `{...}` MUST be a valid JSON5 object.
|
|
3
|
+
// Malformed input throws with a line number — no silent fallback to a string.
|
|
4
|
+
import { parseJson5 } from '@notrealstudio/nr-json5';
|
|
5
|
+
const LBRACE = 123; // {
|
|
6
|
+
const RBRACE = 125; // }
|
|
7
|
+
/** A malformed JSON5 attribute value (same convention as TableParseError, §5). */
|
|
8
|
+
export class Json5ParseError extends Error {
|
|
9
|
+
/** 1-based line in the source document, when known. */
|
|
10
|
+
line;
|
|
11
|
+
/** Attribute key the value belongs to, when known. */
|
|
12
|
+
key;
|
|
13
|
+
constructor(message, line, key) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'Json5ParseError';
|
|
16
|
+
this.line = line;
|
|
17
|
+
this.key = key;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
/** Trim spaces/tabs at both ends (string primitives, no regex). */
|
|
21
|
+
function trim(s) {
|
|
22
|
+
let a = 0;
|
|
23
|
+
let b = s.length;
|
|
24
|
+
while (a < b && (s[a] === ' ' || s[a] === '\t'))
|
|
25
|
+
a++;
|
|
26
|
+
while (b > a && (s[b - 1] === ' ' || s[b - 1] === '\t'))
|
|
27
|
+
b--;
|
|
28
|
+
return s.slice(a, b);
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The shape of a JSON5 object (§3): the value opens with `{` and closes with `}`.
|
|
32
|
+
*
|
|
33
|
+
* Test the RAW value, before unescape. Otherwise `\{literal}` would unfold to
|
|
34
|
+
* `{literal}` first, get caught by the detection, and the escape (§2.5) would
|
|
35
|
+
* stop working.
|
|
36
|
+
*
|
|
37
|
+
* `{name} says hi` opens with `{` but does not close with `}`, so it is not the
|
|
38
|
+
* shape — just prose. Prose and this detection do not collide.
|
|
39
|
+
*/
|
|
40
|
+
export function isJson5Shaped(raw) {
|
|
41
|
+
const s = trim(raw);
|
|
42
|
+
return s.length >= 2 && s.charCodeAt(0) === LBRACE && s.charCodeAt(s.length - 1) === RBRACE;
|
|
43
|
+
}
|
|
44
|
+
/** Flat guard telling a JSON5 object from an InterpolatedValue (`{raw, placeholders}`). */
|
|
45
|
+
export function isJson5Object(v) {
|
|
46
|
+
return (typeof v === 'object' &&
|
|
47
|
+
v !== null &&
|
|
48
|
+
!Array.isArray(v) &&
|
|
49
|
+
!('raw' in v && 'placeholders' in v) &&
|
|
50
|
+
typeof v.cast !== 'function');
|
|
51
|
+
}
|
|
52
|
+
function where(line, key) {
|
|
53
|
+
const k = key === undefined ? 'attribute value' : `$${key}`;
|
|
54
|
+
const l = line === undefined ? '' : ` (line ${line})`;
|
|
55
|
+
return `${k}${l}`;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Strict parse of a value that passed {@link isJson5Shaped} (§3).
|
|
59
|
+
*
|
|
60
|
+
* Single-line is a consequence of position, not a rule: an attribute value does
|
|
61
|
+
* not wrap. A multi-line result substituted into a JSON5 string is invalid and
|
|
62
|
+
* throws, deliberately — multi-line structure is what blocks are for.
|
|
63
|
+
*/
|
|
64
|
+
export function parseJson5Object(raw, line, key) {
|
|
65
|
+
const text = trim(raw);
|
|
66
|
+
let parsed;
|
|
67
|
+
try {
|
|
68
|
+
parsed = parseJson5(text);
|
|
69
|
+
}
|
|
70
|
+
catch (e) {
|
|
71
|
+
const detail = e instanceof Error ? e.message : String(e);
|
|
72
|
+
throw new Json5ParseError(`${where(line, key)}: ${detail}`, line, key);
|
|
73
|
+
}
|
|
74
|
+
// The shape guarantees an object, but parseJson5 returns unknown — narrow honestly.
|
|
75
|
+
if (!isJson5Object(parsed)) {
|
|
76
|
+
throw new Json5ParseError(`${where(line, key)}: JSON5 value is not an object`, line, key);
|
|
77
|
+
}
|
|
78
|
+
return parsed;
|
|
79
|
+
}
|
package/dist/parser.d.ts
ADDED
package/dist/parser.js
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
// Top-level parser for mdd/mdz documents (§2, §6).
|
|
2
|
+
import { parseAttributeValue, parseBodyValue } from './value.js';
|
|
3
|
+
import { isFullyQuoted, unescapeQuoted } from './coerce.js';
|
|
4
|
+
const CC_A_UP = 65;
|
|
5
|
+
const CC_Z_UP = 90;
|
|
6
|
+
const CC_A_LO = 97;
|
|
7
|
+
const CC_Z_LO = 122;
|
|
8
|
+
const CC_0 = 48;
|
|
9
|
+
const CC_9 = 57;
|
|
10
|
+
const CC_UNDER = 95;
|
|
11
|
+
const CC_DASH = 45;
|
|
12
|
+
const CC_DOT = 46;
|
|
13
|
+
function isNameStart(c) {
|
|
14
|
+
return (c >= CC_A_UP && c <= CC_Z_UP) || (c >= CC_A_LO && c <= CC_Z_LO) || c === CC_UNDER;
|
|
15
|
+
}
|
|
16
|
+
function isNameCont(c) {
|
|
17
|
+
return (isNameStart(c) ||
|
|
18
|
+
(c >= CC_0 && c <= CC_9) ||
|
|
19
|
+
c === CC_DASH);
|
|
20
|
+
}
|
|
21
|
+
function isBlockNameCont(c) {
|
|
22
|
+
return isNameCont(c) || c === CC_DOT;
|
|
23
|
+
}
|
|
24
|
+
/** Read `#`-prefix and return raw hash count, or null. */
|
|
25
|
+
function readHashes(line) {
|
|
26
|
+
let n = 0;
|
|
27
|
+
while (n < line.length && line[n] === '#')
|
|
28
|
+
n++;
|
|
29
|
+
if (n === 0)
|
|
30
|
+
return null;
|
|
31
|
+
return { count: n, next: n };
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Try to parse a markdown heading as a structured block header.
|
|
35
|
+
*
|
|
36
|
+
* Returns null when the line isn't a structured header (regular markdown
|
|
37
|
+
* heading, or just non-header text).
|
|
38
|
+
*/
|
|
39
|
+
function parseHeader(line, sigil) {
|
|
40
|
+
const hash = readHashes(line);
|
|
41
|
+
if (!hash)
|
|
42
|
+
return null;
|
|
43
|
+
if (hash.count < 1 || hash.count > 6)
|
|
44
|
+
return null;
|
|
45
|
+
if (line[hash.next] !== ' ')
|
|
46
|
+
return null;
|
|
47
|
+
let i = hash.next + 1;
|
|
48
|
+
if (line[i] !== sigil)
|
|
49
|
+
return null;
|
|
50
|
+
const rest = line.slice(i);
|
|
51
|
+
// Closing forms.
|
|
52
|
+
if (rest === sigil) {
|
|
53
|
+
return { level: hash.count, closing: true, name: '' };
|
|
54
|
+
}
|
|
55
|
+
if (sigil === '$' && rest === '$@') {
|
|
56
|
+
return { level: hash.count, closing: true, name: '' };
|
|
57
|
+
}
|
|
58
|
+
if (sigil === '@' && rest === '@@') {
|
|
59
|
+
return { level: hash.count, closing: true, name: '' };
|
|
60
|
+
}
|
|
61
|
+
if (sigil === '$' && rest === '$$') {
|
|
62
|
+
// `## $$` reserved (Obsidian math conflict, §2.1) — not a header.
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
i++; // consume sigil
|
|
66
|
+
// Block name (allows dot).
|
|
67
|
+
if (i >= line.length)
|
|
68
|
+
return null;
|
|
69
|
+
if (!isNameStart(line.charCodeAt(i)))
|
|
70
|
+
return null;
|
|
71
|
+
const nameStart = i;
|
|
72
|
+
i++;
|
|
73
|
+
while (i < line.length && isBlockNameCont(line.charCodeAt(i)))
|
|
74
|
+
i++;
|
|
75
|
+
const name = line.slice(nameStart, i);
|
|
76
|
+
let id;
|
|
77
|
+
if (i < line.length) {
|
|
78
|
+
if (line[i] !== ' ')
|
|
79
|
+
return null;
|
|
80
|
+
const tail = line.slice(i + 1).trim();
|
|
81
|
+
// Bare id — rest of the line as-is. Quotes are for an id that bare form
|
|
82
|
+
// would lose: edge whitespace, or an empty string (§2.1).
|
|
83
|
+
if (tail.length > 0)
|
|
84
|
+
id = isFullyQuoted(tail) ? unescapeQuoted(tail.slice(1, -1)) : tail;
|
|
85
|
+
}
|
|
86
|
+
return { level: hash.count, closing: false, name, id };
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse a line as `$key: value` / `$a.b: value`. Returns null if the line
|
|
90
|
+
* is not a valid attribute.
|
|
91
|
+
*/
|
|
92
|
+
function parseAttribute(line, sigil) {
|
|
93
|
+
if (line.length === 0)
|
|
94
|
+
return null;
|
|
95
|
+
if (line[0] !== sigil)
|
|
96
|
+
return null;
|
|
97
|
+
let i = 1;
|
|
98
|
+
if (i >= line.length)
|
|
99
|
+
return null;
|
|
100
|
+
if (!isNameStart(line.charCodeAt(i)))
|
|
101
|
+
return null;
|
|
102
|
+
// Key is a single literal name; a dot is part of the name (§2.2), exactly like
|
|
103
|
+
// block names (§2.1). NO path-splitting, NO escape on the key side — addressing
|
|
104
|
+
// a dotted key from a placeholder uses the escape there (§7.1), not here.
|
|
105
|
+
const keyStart = i;
|
|
106
|
+
i++;
|
|
107
|
+
while (i < line.length && isBlockNameCont(line.charCodeAt(i)))
|
|
108
|
+
i++;
|
|
109
|
+
const segs = [line.slice(keyStart, i)];
|
|
110
|
+
if (line[i] !== ':')
|
|
111
|
+
return null;
|
|
112
|
+
i++;
|
|
113
|
+
if (line[i] === ' ')
|
|
114
|
+
i++;
|
|
115
|
+
return { key: segs, rawValue: line.slice(i) };
|
|
116
|
+
}
|
|
117
|
+
// ---------- Body line ----------
|
|
118
|
+
/** A line that does not match any structured form contributes to body. */
|
|
119
|
+
// ---------- Driver ----------
|
|
120
|
+
export function parse(text, options = {}) {
|
|
121
|
+
const sigil = options.sigil ?? '$';
|
|
122
|
+
const baseLevel = options.baseLevel ?? 1;
|
|
123
|
+
if (baseLevel < 1 || baseLevel > 6) {
|
|
124
|
+
throw new Error(`baseLevel must be in 1..6, got ${baseLevel}`);
|
|
125
|
+
}
|
|
126
|
+
// Normalise line endings (§2: parser normalises \r\n → \n).
|
|
127
|
+
const normalised = normaliseNewlines(text);
|
|
128
|
+
const lines = normalised.split('\n');
|
|
129
|
+
const root = { name: '', level: 0, attrs: [], children: [] };
|
|
130
|
+
const stack = [root];
|
|
131
|
+
let bodyLines = [];
|
|
132
|
+
// Raw (un-unescaped) body per block — the source of truth when stanzas are
|
|
133
|
+
// joined. The body stays raw until the final assembly, and unescape runs
|
|
134
|
+
// exactly once, in parseBodyValue, over the COMPLETE raw text (§2.5).
|
|
135
|
+
// Unescaping per fragment would double-unescape a resumed body (\\$ → \$ → $).
|
|
136
|
+
const bodyRaw = new Map();
|
|
137
|
+
const flushBody = (target) => {
|
|
138
|
+
// Drop trailing/leading blank-only lines — they are "separators" (§2.3).
|
|
139
|
+
let start = 0;
|
|
140
|
+
let end = bodyLines.length;
|
|
141
|
+
while (start < end && bodyLines[start].length === 0)
|
|
142
|
+
start++;
|
|
143
|
+
while (end > start && bodyLines[end - 1].length === 0)
|
|
144
|
+
end--;
|
|
145
|
+
if (start < end) {
|
|
146
|
+
const frag = bodyLines.slice(start, end).join('\n');
|
|
147
|
+
// Body resumed after a nested block closed → append raw as strophes.
|
|
148
|
+
const prev = bodyRaw.get(target);
|
|
149
|
+
const full = prev === undefined ? frag : prev + '\n\n' + frag;
|
|
150
|
+
bodyRaw.set(target, full);
|
|
151
|
+
target.body = parseBodyValue(full); // one unescape, over the whole raw
|
|
152
|
+
}
|
|
153
|
+
bodyLines = [];
|
|
154
|
+
};
|
|
155
|
+
const top = () => stack[stack.length - 1];
|
|
156
|
+
for (let li = 0; li < lines.length; li++) {
|
|
157
|
+
const line = lines[li];
|
|
158
|
+
const header = parseHeader(line, sigil);
|
|
159
|
+
if (header) {
|
|
160
|
+
// Effective level — header.level minus (baseLevel - 1).
|
|
161
|
+
const effLevel = header.level - (baseLevel - 1);
|
|
162
|
+
if (header.closing) {
|
|
163
|
+
flushBody(top());
|
|
164
|
+
// Pop everything back to root (global zone).
|
|
165
|
+
while (stack.length > 1)
|
|
166
|
+
stack.pop();
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (effLevel < 1) {
|
|
170
|
+
// Heading below baseLevel — treat as body content (regular markdown).
|
|
171
|
+
bodyLines.push(line);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
flushBody(top());
|
|
175
|
+
// Pop until parent's level < new block's level.
|
|
176
|
+
while (stack.length > 1 && top().level >= effLevel) {
|
|
177
|
+
stack.pop();
|
|
178
|
+
}
|
|
179
|
+
const block = {
|
|
180
|
+
name: header.name,
|
|
181
|
+
level: effLevel,
|
|
182
|
+
attrs: [],
|
|
183
|
+
children: [],
|
|
184
|
+
};
|
|
185
|
+
if (header.id !== undefined)
|
|
186
|
+
block.id = header.id;
|
|
187
|
+
top().children.push(block);
|
|
188
|
+
stack.push(block);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const attr = parseAttribute(line, sigil);
|
|
192
|
+
if (attr) {
|
|
193
|
+
// li is a 0-based index into the lines; errors (Json5ParseError) are 1-based.
|
|
194
|
+
const value = parseAttributeValue(attr.rawValue, sigil, li + 1, attr.key.join('.'));
|
|
195
|
+
const a = { key: attr.key, value };
|
|
196
|
+
top().attrs.push(a);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
// Anything else is a body line. Empty lines are kept for now; they may
|
|
200
|
+
// get trimmed by flushBody if they sit at the edges.
|
|
201
|
+
bodyLines.push(line);
|
|
202
|
+
}
|
|
203
|
+
// Flush trailing body of every open block.
|
|
204
|
+
while (stack.length > 0) {
|
|
205
|
+
flushBody(top());
|
|
206
|
+
stack.pop();
|
|
207
|
+
}
|
|
208
|
+
return { sigil, root };
|
|
209
|
+
}
|
|
210
|
+
function normaliseNewlines(s) {
|
|
211
|
+
let out = '';
|
|
212
|
+
let i = 0;
|
|
213
|
+
while (i < s.length) {
|
|
214
|
+
const c = s[i];
|
|
215
|
+
if (c === '\r') {
|
|
216
|
+
out += '\n';
|
|
217
|
+
if (s[i + 1] === '\n')
|
|
218
|
+
i += 2;
|
|
219
|
+
else
|
|
220
|
+
i++;
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
out += c;
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { Sigil } from './types.js';
|
|
2
|
+
import { type JSONSchema } from './typed-header.js';
|
|
3
|
+
export type { JSONSchema } from './typed-header.js';
|
|
4
|
+
/**
|
|
5
|
+
* Where a field is stored in mdd/mdz (serialize-spec §3.2).
|
|
6
|
+
*
|
|
7
|
+
* `block` — the field travels as its OWN child block, the value in that block's
|
|
8
|
+
* body. Unlike `body` (the parent block's body, so at most one such field), an
|
|
9
|
+
* object may have any number of these: the prose fields (description, scenario…).
|
|
10
|
+
*/
|
|
11
|
+
export type XStorage = 'attr' | 'body' | 'name' | 'id' | 'block';
|
|
12
|
+
/** How an array is laid out (`x-mdd.list`, §3.2). */
|
|
13
|
+
export type XListMode = 'flow' | 'tbl' | 'lines' | 'blocks';
|
|
14
|
+
/** Representation refinement (`x-mdd.as`, §3.9) — `json`: a fenced JSON block (opaque). */
|
|
15
|
+
export type XAs = 'json';
|
|
16
|
+
/**
|
|
17
|
+
* Key of the catch-all bag holding fields NOT described by the schema.
|
|
18
|
+
*
|
|
19
|
+
* Enabled per object schema with `x-mdd: { unknown: 'block' }`; `parseWithSchema`
|
|
20
|
+
* collects every unclaimed attribute/block here and `serializeWithSchema` emits
|
|
21
|
+
* the bag back — round-trip of foreign extensions is lossless.
|
|
22
|
+
*/
|
|
23
|
+
export declare const UNKNOWN_KEY = "$unknown";
|
|
24
|
+
/**
|
|
25
|
+
* Block name of the overflow bag: unknown keys that are NOT valid mdd names
|
|
26
|
+
* (spaces, leading digits…) cannot be an attribute or a block header, so they
|
|
27
|
+
* travel together in one fenced-JSON block instead of being dropped.
|
|
28
|
+
*/
|
|
29
|
+
export declare const UNKNOWN_OVERFLOW_BLOCK = "_unknown";
|
|
30
|
+
export interface SchemaSerializeOptions {
|
|
31
|
+
sigil?: Sigil;
|
|
32
|
+
baseLevel?: number;
|
|
33
|
+
eol?: '\n' | '\r\n';
|
|
34
|
+
/** Diagnostics sink (lossy fallbacks, type conflicts). Default: `console.warn`. */
|
|
35
|
+
warn?: (msg: string) => void;
|
|
36
|
+
}
|
|
37
|
+
export interface ParseWithSchemaOptions {
|
|
38
|
+
sigil?: Sigil;
|
|
39
|
+
baseLevel?: number;
|
|
40
|
+
/** Diagnostics sink (type conflicts between inline tbl header and schema). Default: `console.warn`. */
|
|
41
|
+
warn?: (msg: string) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface ValidationError {
|
|
44
|
+
/** JSON-pointer-ish path to the offending value (`` for root). */
|
|
45
|
+
path: string;
|
|
46
|
+
message: string;
|
|
47
|
+
}
|
|
48
|
+
export interface ValidationResult {
|
|
49
|
+
valid: boolean;
|
|
50
|
+
errors: ValidationError[];
|
|
51
|
+
}
|
|
52
|
+
export interface ValidateOptions {
|
|
53
|
+
/** Inject a validator (e.g. a native one) instead of the Ajv delegate. */
|
|
54
|
+
validator?: (obj: unknown, schema: JSONSchema) => ValidationResult;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Serialize an object to mdd/mdz text through a JSON Schema (serialize-spec §3.3).
|
|
58
|
+
*
|
|
59
|
+
* Layout by `x-storage` (attr | body | name | id) and `x-mdd` (list: flow | tbl |
|
|
60
|
+
* lines | blocks, unknown: block). Field order = order of schema `properties` —
|
|
61
|
+
* deterministic, so the same object always serializes byte-for-byte identically.
|
|
62
|
+
*/
|
|
63
|
+
export declare function serializeWithSchema(obj: unknown, schema: JSONSchema, opts?: SchemaSerializeOptions): string;
|
|
64
|
+
/**
|
|
65
|
+
* Parse mdd/mdz text back into an object through a JSON Schema (serialize-spec §3.3).
|
|
66
|
+
*
|
|
67
|
+
* Inverse of {@link serializeWithSchema}: reassembles the object from x-storage
|
|
68
|
+
* placement and coerces each field by its schema type (schema type wins over
|
|
69
|
+
* YAML coercion). Generic `<T>` is a TS-only convenience — no runtime effect.
|
|
70
|
+
*/
|
|
71
|
+
export declare function parseWithSchema<T = unknown>(text: string, schema: JSONSchema, opts?: ParseWithSchemaOptions): T;
|
|
72
|
+
/**
|
|
73
|
+
* Validate an object against a schema (serialize-spec §3.4).
|
|
74
|
+
*
|
|
75
|
+
* Delegates to Ajv2020 with `addVocabulary(['x-storage','x-mdd','x-ui'])` so the
|
|
76
|
+
* strict mode does not choke on our x-extensions (nr-schema §5). Ajv is an
|
|
77
|
+
* OPTIONAL peer — when it is not installed and no `opts.validator` is provided,
|
|
78
|
+
* a clear error is thrown ("install ajv or provide a validator").
|
|
79
|
+
*/
|
|
80
|
+
export declare function validate(obj: unknown, schema: JSONSchema, opts?: ValidateOptions): ValidationResult;
|