@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,229 @@
|
|
|
1
|
+
// Typed tbl header (serialize-spec §2.3 / format-spec §5.4).
|
|
2
|
+
//
|
|
3
|
+
// A tbl header cell may carry an optional type annotation: `name[:type][!]`.
|
|
4
|
+
// Without an annotation the column keeps the legacy per-cell coercion (§3).
|
|
5
|
+
// A typed column is homogeneous: every cell coerces to the declared type and an
|
|
6
|
+
// impossible coercion is a LOUD error (TableParseError), never a silent string.
|
|
7
|
+
//
|
|
8
|
+
// Portable: string primitives only, no regex.
|
|
9
|
+
import { coerce, unescapeQuoted } from './coerce.js';
|
|
10
|
+
/** Loud, non-silent table-parse error (serialize-spec §6). */
|
|
11
|
+
export class TableParseError extends Error {
|
|
12
|
+
constructor(message) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.name = 'TableParseError';
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
// ---------- Small helpers (no regex) ----------
|
|
18
|
+
function trimWs(s) {
|
|
19
|
+
let a = 0;
|
|
20
|
+
let b = s.length;
|
|
21
|
+
while (a < b && (s[a] === ' ' || s[a] === '\t'))
|
|
22
|
+
a++;
|
|
23
|
+
while (b > a && (s[b - 1] === ' ' || s[b - 1] === '\t'))
|
|
24
|
+
b--;
|
|
25
|
+
return s.slice(a, b);
|
|
26
|
+
}
|
|
27
|
+
function isQuoted(s) {
|
|
28
|
+
return s.length >= 2 && s[0] === '"' && s[s.length - 1] === '"';
|
|
29
|
+
}
|
|
30
|
+
// ---------- Header cell parsing (§2.3) ----------
|
|
31
|
+
/**
|
|
32
|
+
* Split a header cell into its name (with `\:` / `\\` resolved) and the raw
|
|
33
|
+
* type-spec after the first UNescaped `:` (or null when there is no annotation).
|
|
34
|
+
*/
|
|
35
|
+
function splitNameAndType(t) {
|
|
36
|
+
let name = '';
|
|
37
|
+
let i = 0;
|
|
38
|
+
while (i < t.length) {
|
|
39
|
+
const c = t[i];
|
|
40
|
+
if (c === '\\' && i + 1 < t.length) {
|
|
41
|
+
const n = t[i + 1];
|
|
42
|
+
if (n === ':') {
|
|
43
|
+
name += ':';
|
|
44
|
+
i += 2;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (n === '\\') {
|
|
48
|
+
name += '\\';
|
|
49
|
+
i += 2;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
// Unknown escape — preserve the backslash verbatim.
|
|
53
|
+
name += c;
|
|
54
|
+
i++;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (c === ':')
|
|
58
|
+
return { name, typeSpec: t.slice(i + 1) };
|
|
59
|
+
name += c;
|
|
60
|
+
i++;
|
|
61
|
+
}
|
|
62
|
+
return { name, typeSpec: null };
|
|
63
|
+
}
|
|
64
|
+
function parseTypeSpec(s) {
|
|
65
|
+
if (s === 'string')
|
|
66
|
+
return { kind: 'string' };
|
|
67
|
+
if (s === 'number')
|
|
68
|
+
return { kind: 'number' };
|
|
69
|
+
if (s === 'boolean')
|
|
70
|
+
return { kind: 'boolean' };
|
|
71
|
+
if (s.length >= 6 && s.slice(0, 5) === 'list(' && s[s.length - 1] === ')') {
|
|
72
|
+
const sep = s.slice(5, -1);
|
|
73
|
+
if (sep.length !== 1) {
|
|
74
|
+
throw new TableParseError(`list separator must be one character, got "${sep}"`);
|
|
75
|
+
}
|
|
76
|
+
return { kind: 'list', sep };
|
|
77
|
+
}
|
|
78
|
+
throw new TableParseError(`unknown column type: "${s}"`);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Parse a single header cell (`name[:type][!]`, §2.3).
|
|
82
|
+
*
|
|
83
|
+
* - A fully double-quoted cell is a literal name — no annotation.
|
|
84
|
+
* - `\:` escapes a literal colon in the name; `\\` a literal backslash.
|
|
85
|
+
* - `!` (after the optional type) marks the column required.
|
|
86
|
+
* - Un-annotated cells keep legacy behaviour (per-cell coercion §3).
|
|
87
|
+
*/
|
|
88
|
+
export function parseHeaderCell(cell) {
|
|
89
|
+
const t = trimWs(cell);
|
|
90
|
+
if (isQuoted(t)) {
|
|
91
|
+
return { name: unescapeQuoted(t.slice(1, -1)), required: false };
|
|
92
|
+
}
|
|
93
|
+
const { name, typeSpec } = splitNameAndType(t);
|
|
94
|
+
if (typeSpec !== null) {
|
|
95
|
+
let spec = trimWs(typeSpec);
|
|
96
|
+
let required = false;
|
|
97
|
+
if (spec.length > 0 && spec[spec.length - 1] === '!') {
|
|
98
|
+
required = true;
|
|
99
|
+
spec = trimWs(spec.slice(0, -1));
|
|
100
|
+
}
|
|
101
|
+
return { name: trimWs(name), type: parseTypeSpec(spec), required };
|
|
102
|
+
}
|
|
103
|
+
// No type annotation — still allow a trailing `!` for a required legacy column.
|
|
104
|
+
let bare = name;
|
|
105
|
+
let required = false;
|
|
106
|
+
if (bare.length > 0 && bare[bare.length - 1] === '!') {
|
|
107
|
+
required = true;
|
|
108
|
+
bare = bare.slice(0, -1);
|
|
109
|
+
}
|
|
110
|
+
return { name: trimWs(bare), required };
|
|
111
|
+
}
|
|
112
|
+
/** Parse a full header line into typed columns (used by {@link tableSchema}). */
|
|
113
|
+
export function parseHeaderLine(line, splitCells) {
|
|
114
|
+
return splitCells(line).map(parseHeaderCell);
|
|
115
|
+
}
|
|
116
|
+
// ---------- Header cell emit (§2.3 serialize) ----------
|
|
117
|
+
function typeSpecText(type) {
|
|
118
|
+
switch (type.kind) {
|
|
119
|
+
case 'string': return 'string';
|
|
120
|
+
case 'number': return 'number';
|
|
121
|
+
case 'boolean': return 'boolean';
|
|
122
|
+
case 'list': return `list(${type.sep})`;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
/** Escape `\` and `:` in a typed column name for header emission. */
|
|
126
|
+
function escapeName(name) {
|
|
127
|
+
let out = '';
|
|
128
|
+
for (let i = 0; i < name.length; i++) {
|
|
129
|
+
const c = name[i];
|
|
130
|
+
if (c === '\\')
|
|
131
|
+
out += '\\\\';
|
|
132
|
+
else if (c === ':')
|
|
133
|
+
out += '\\:';
|
|
134
|
+
else
|
|
135
|
+
out += c;
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Emit a header cell. Typed columns emit `name[:type][!]` with `\:` escaping;
|
|
141
|
+
* un-annotated columns fall back to the caller's plain cell serializer.
|
|
142
|
+
*/
|
|
143
|
+
export function emitHeaderCell(col, plainCell) {
|
|
144
|
+
if (!col.type && !col.required)
|
|
145
|
+
return plainCell(col.name);
|
|
146
|
+
let out = escapeName(col.name);
|
|
147
|
+
if (col.type)
|
|
148
|
+
out += ':' + typeSpecText(col.type);
|
|
149
|
+
if (col.required)
|
|
150
|
+
out += '!';
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
// ---------- Homogeneous cell coercion (§2.3) ----------
|
|
154
|
+
function stripQuotesForList(raw) {
|
|
155
|
+
return isQuoted(raw) ? unescapeQuoted(raw.slice(1, -1)) : raw;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Coerce a raw cell to the column's declared type (§2.3). Un-annotated columns
|
|
159
|
+
* fall back to §3 coercion. Impossible coercions and empty required cells throw
|
|
160
|
+
* {@link TableParseError} — never a silent string.
|
|
161
|
+
*/
|
|
162
|
+
export function coerceTyped(raw, col) {
|
|
163
|
+
const isEmpty = raw === '';
|
|
164
|
+
if (isEmpty && col.required) {
|
|
165
|
+
throw new TableParseError(`column "${col.name}": required cell is empty`);
|
|
166
|
+
}
|
|
167
|
+
const type = col.type;
|
|
168
|
+
if (!type)
|
|
169
|
+
return coerce(raw);
|
|
170
|
+
switch (type.kind) {
|
|
171
|
+
case 'string': {
|
|
172
|
+
if (isEmpty)
|
|
173
|
+
return '';
|
|
174
|
+
return isQuoted(raw) ? unescapeQuoted(raw.slice(1, -1)) : raw;
|
|
175
|
+
}
|
|
176
|
+
case 'number': {
|
|
177
|
+
if (isEmpty)
|
|
178
|
+
return null;
|
|
179
|
+
const c = coerce(raw);
|
|
180
|
+
if (typeof c === 'number')
|
|
181
|
+
return c;
|
|
182
|
+
throw new TableParseError(`column "${col.name}": "${raw}" is not a number`);
|
|
183
|
+
}
|
|
184
|
+
case 'boolean': {
|
|
185
|
+
if (isEmpty)
|
|
186
|
+
return null;
|
|
187
|
+
const c = coerce(raw);
|
|
188
|
+
if (typeof c === 'boolean')
|
|
189
|
+
return c;
|
|
190
|
+
throw new TableParseError(`column "${col.name}": "${raw}" is not a boolean`);
|
|
191
|
+
}
|
|
192
|
+
case 'list': {
|
|
193
|
+
if (isEmpty)
|
|
194
|
+
return [];
|
|
195
|
+
const body = stripQuotesForList(raw);
|
|
196
|
+
return body.split(type.sep).map((e) => coerce(trimWs(e)));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// ---------- Derived JSON Schema (§2.3, tableSchema) ----------
|
|
201
|
+
function typeToSchema(type) {
|
|
202
|
+
if (!type)
|
|
203
|
+
return {};
|
|
204
|
+
switch (type.kind) {
|
|
205
|
+
case 'string': return { type: 'string' };
|
|
206
|
+
case 'number': return { type: 'number' };
|
|
207
|
+
case 'boolean': return { type: 'boolean' };
|
|
208
|
+
case 'list': return { type: 'array', 'x-mdd': { separator: type.sep } };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Derive a JSON Schema fragment from a typed header (serialize-spec §2.3).
|
|
213
|
+
*
|
|
214
|
+
* `weight:number` → `{type:'number'}`, `!` → required, `list(;)` →
|
|
215
|
+
* `{type:'array', 'x-mdd':{separator:';'}}`. The result is derived, never stored.
|
|
216
|
+
*/
|
|
217
|
+
export function tableSchema(columns) {
|
|
218
|
+
const properties = {};
|
|
219
|
+
const required = [];
|
|
220
|
+
for (const col of columns) {
|
|
221
|
+
properties[col.name] = typeToSchema(col.type);
|
|
222
|
+
if (col.required)
|
|
223
|
+
required.push(col.name);
|
|
224
|
+
}
|
|
225
|
+
const schema = { type: 'object', properties };
|
|
226
|
+
if (required.length > 0)
|
|
227
|
+
schema.required = required;
|
|
228
|
+
return schema;
|
|
229
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/** Format sigil — `$` for mdz, `@` for mdd. */
|
|
2
|
+
export type Sigil = '@' | '$';
|
|
3
|
+
export type Scalar = string | number | boolean | null;
|
|
4
|
+
/**
|
|
5
|
+
* A recognised `${...}` interpolation, as a span in the containing raw text.
|
|
6
|
+
*
|
|
7
|
+
* nr-md lexes interpolations, it does not parse them: the span says WHERE the
|
|
8
|
+
* expression is, never what it means. The expression grammar belongs to whoever
|
|
9
|
+
* evaluates it — a different layer, possibly a different language.
|
|
10
|
+
*/
|
|
11
|
+
export interface InterpolationSpan {
|
|
12
|
+
/** Text between `${` and the matching `}`, verbatim. */
|
|
13
|
+
raw: string;
|
|
14
|
+
/** Offset of the `$` in the containing raw value. */
|
|
15
|
+
start: number;
|
|
16
|
+
/** Offset just past the closing `}` (exclusive). */
|
|
17
|
+
end: number;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* A value that carries at least one `${...}` (or the `\${` opt-out marker).
|
|
21
|
+
*
|
|
22
|
+
* `raw` is the fact: original text, escapes NOT unfolded, interpolations left
|
|
23
|
+
* in place. Consumers that resolve interpolations read `raw`; `placeholders`
|
|
24
|
+
* is the lexer's index into it.
|
|
25
|
+
*/
|
|
26
|
+
export interface InterpolatedValue {
|
|
27
|
+
raw: string;
|
|
28
|
+
placeholders: InterpolationSpan[];
|
|
29
|
+
}
|
|
30
|
+
export type ListItem = Scalar | InterpolatedValue;
|
|
31
|
+
/** A value inside a JSON5 object: scalars, arrays, nested objects. */
|
|
32
|
+
export type Json5Value = Scalar | Json5Value[] | Json5Object;
|
|
33
|
+
/**
|
|
34
|
+
* A JSON5 object used as an attribute value (§3; json5-scalar-spec).
|
|
35
|
+
*
|
|
36
|
+
* A plain object — no behaviour attached. A path into it (`${attr.path}`) is
|
|
37
|
+
* the evaluator's business, not the parser's.
|
|
38
|
+
*/
|
|
39
|
+
export interface Json5Object {
|
|
40
|
+
[key: string]: Json5Value;
|
|
41
|
+
}
|
|
42
|
+
export type AttributeValue = Scalar | ListItem[] | InterpolatedValue | Json5Object;
|
|
43
|
+
export type BodyValue = string | InterpolatedValue;
|
|
44
|
+
/**
|
|
45
|
+
* Source position of a tree node.
|
|
46
|
+
*
|
|
47
|
+
* Reserved slot: nothing populates it yet. It exists so that adding position
|
|
48
|
+
* tracking later cannot break an exhaustive walk written against this AST.
|
|
49
|
+
*/
|
|
50
|
+
export interface Pos {
|
|
51
|
+
/** 1-based line in the source text. */
|
|
52
|
+
line: number;
|
|
53
|
+
/** 1-based column. */
|
|
54
|
+
col: number;
|
|
55
|
+
/** 0-based character offset. */
|
|
56
|
+
offset: number;
|
|
57
|
+
}
|
|
58
|
+
export interface Attribute {
|
|
59
|
+
/** Dotted key as segments (§2.2). Single-segment keys → array of length 1. */
|
|
60
|
+
key: string[];
|
|
61
|
+
value: AttributeValue;
|
|
62
|
+
/** Reserved — see {@link Pos}. Not populated. */
|
|
63
|
+
pos?: Pos;
|
|
64
|
+
}
|
|
65
|
+
export interface Block {
|
|
66
|
+
/** Block name (after the sigil in the header). The root pseudo-block has an empty name. */
|
|
67
|
+
name: string;
|
|
68
|
+
/** Optional id from the header (§2.1). */
|
|
69
|
+
id?: string;
|
|
70
|
+
/** Effective level (1..6) after the baseLevel shift. Root is 0. */
|
|
71
|
+
level: number;
|
|
72
|
+
/** Attributes in order of appearance (relevant for §2.4 last-wins). */
|
|
73
|
+
attrs: Attribute[];
|
|
74
|
+
/** Block body, or undefined if empty. */
|
|
75
|
+
body?: BodyValue;
|
|
76
|
+
/** Child blocks in order of appearance. */
|
|
77
|
+
children: Block[];
|
|
78
|
+
/** Reserved — see {@link Pos}. Not populated. */
|
|
79
|
+
pos?: Pos;
|
|
80
|
+
}
|
|
81
|
+
export interface Document {
|
|
82
|
+
sigil: Sigil;
|
|
83
|
+
root: Block;
|
|
84
|
+
}
|
|
85
|
+
export interface ParseOptions {
|
|
86
|
+
/** Format sigil — `$` for mdz (default), `@` for mdd. */
|
|
87
|
+
sigil?: Sigil;
|
|
88
|
+
/** Offset added to the header `#` count; default 1 (h1 root). */
|
|
89
|
+
baseLevel?: number;
|
|
90
|
+
}
|
|
91
|
+
export interface SerializeOptions {
|
|
92
|
+
/**
|
|
93
|
+
* Format sigil to emit — `$` (mdz) | `@` (mdd).
|
|
94
|
+
* Defaults to the document's own sigil (`doc.sigil`).
|
|
95
|
+
*/
|
|
96
|
+
sigil?: Sigil;
|
|
97
|
+
/** Level of root blocks; mirrors ParseOptions.baseLevel. Default 1. */
|
|
98
|
+
baseLevel?: number;
|
|
99
|
+
/** Line ending. Default `\n`. */
|
|
100
|
+
eol?: '\n' | '\r\n';
|
|
101
|
+
}
|
package/dist/types.js
ADDED
package/dist/value.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { AttributeValue, BodyValue, Sigil } from './types.js';
|
|
2
|
+
interface EscapeMode {
|
|
3
|
+
/** Inside a flow-literal `[...]` element — `\,` is meaningful (§2.5). */
|
|
4
|
+
flow: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Unfold escape sequences in a value (§2.5).
|
|
8
|
+
*
|
|
9
|
+
* Always recognised: `\$`, `\@`, `\[`, `\{`, `\\`, `\n`, `\t`, `\${`.
|
|
10
|
+
* Context-dependent: `\,` (flow only); `\.` only inside `${}` paths and so
|
|
11
|
+
* is meaningful only inside `${}`, where this module does not reach.
|
|
12
|
+
*
|
|
13
|
+
* Note on `\${`: this branch is the RESOLVER's final-pass behaviour (run once,
|
|
14
|
+
* after all recursion settles). The parser must NOT feed `\${`-bearing values
|
|
15
|
+
* here — see hasOptOutEscape — or opt-out is lost when the value flows through
|
|
16
|
+
* recursion.
|
|
17
|
+
*
|
|
18
|
+
* Any other `\X` is preserved verbatim (backslash + char).
|
|
19
|
+
*/
|
|
20
|
+
export declare function unescape(s: string, mode: EscapeMode): string;
|
|
21
|
+
/**
|
|
22
|
+
* Parse the raw text after an attribute's `:` (with surrounding ws stripped
|
|
23
|
+
* by the caller — single optional space after `:` already consumed; trailing
|
|
24
|
+
* trimmed). Returns a coerced scalar, a list (flow-literal), or an
|
|
25
|
+
* InterpolatedValue when `${}` is present.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parseAttributeValue(raw: string, sigil: Sigil, line?: number, key?: string): AttributeValue;
|
|
28
|
+
/**
|
|
29
|
+
* Parse a body string. Body is raw text; we only recognise `${}` and unfold
|
|
30
|
+
* escapes (§2.5 says escapes act in body too).
|
|
31
|
+
*/
|
|
32
|
+
export declare function parseBodyValue(raw: string): BodyValue;
|
|
33
|
+
export {};
|
package/dist/value.js
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// Value parsing — escapes (§2.5), flow literals (§4), interpolation lexing (§7).
|
|
2
|
+
import { coerce, isFullyQuoted } from './coerce.js';
|
|
3
|
+
import { isJson5Shaped, parseJson5Object } from './json5-value.js';
|
|
4
|
+
import { findMatchingBrace, hasOptOutEscape, scanInterpolations } from './interpolation.js';
|
|
5
|
+
/**
|
|
6
|
+
* Unfold escape sequences in a value (§2.5).
|
|
7
|
+
*
|
|
8
|
+
* Always recognised: `\$`, `\@`, `\[`, `\{`, `\\`, `\n`, `\t`, `\${`.
|
|
9
|
+
* Context-dependent: `\,` (flow only); `\.` only inside `${}` paths and so
|
|
10
|
+
* is meaningful only inside `${}`, where this module does not reach.
|
|
11
|
+
*
|
|
12
|
+
* Note on `\${`: this branch is the RESOLVER's final-pass behaviour (run once,
|
|
13
|
+
* after all recursion settles). The parser must NOT feed `\${`-bearing values
|
|
14
|
+
* here — see hasOptOutEscape — or opt-out is lost when the value flows through
|
|
15
|
+
* recursion.
|
|
16
|
+
*
|
|
17
|
+
* Any other `\X` is preserved verbatim (backslash + char).
|
|
18
|
+
*/
|
|
19
|
+
export function unescape(s, mode) {
|
|
20
|
+
let out = '';
|
|
21
|
+
let i = 0;
|
|
22
|
+
while (i < s.length) {
|
|
23
|
+
if (s[i] === '\\' && i + 1 < s.length) {
|
|
24
|
+
// `\${` — a literal `${` (interpolation opt-out).
|
|
25
|
+
if (s[i + 1] === '$' && s[i + 2] === '{') {
|
|
26
|
+
out += '${';
|
|
27
|
+
i += 3;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const n = s[i + 1];
|
|
31
|
+
// `\{` — a literal `{` (§2.5): a value wrapped in braces stays a string
|
|
32
|
+
// instead of becoming a JSON5 object. Checked after `\${` above, so the
|
|
33
|
+
// interpolation opt-out is untouched.
|
|
34
|
+
if (n === '$' || n === '@' || n === '[' || n === '{' || n === '\\') {
|
|
35
|
+
out += n;
|
|
36
|
+
i += 2;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (mode.flow && n === ',') {
|
|
40
|
+
out += ',';
|
|
41
|
+
i += 2;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
// Unknown — preserve verbatim.
|
|
45
|
+
out += '\\' + n;
|
|
46
|
+
i += 2;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
out += s[i];
|
|
50
|
+
i++;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
// ---------- Flow-literal splitting (§4) ----------
|
|
55
|
+
/**
|
|
56
|
+
* Split flow-literal body into elements by top-level commas, respecting
|
|
57
|
+
* string literals, escape sequences and nested `${...}`.
|
|
58
|
+
*/
|
|
59
|
+
function splitFlowElements(s) {
|
|
60
|
+
const out = [];
|
|
61
|
+
let buf = '';
|
|
62
|
+
let i = 0;
|
|
63
|
+
let inStr = false;
|
|
64
|
+
while (i < s.length) {
|
|
65
|
+
const c = s[i];
|
|
66
|
+
if (inStr) {
|
|
67
|
+
buf += c;
|
|
68
|
+
if (c === '\\' && i + 1 < s.length) {
|
|
69
|
+
buf += s[i + 1];
|
|
70
|
+
i += 2;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (c === '"')
|
|
74
|
+
inStr = false;
|
|
75
|
+
i++;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (c === '\\' && i + 1 < s.length) {
|
|
79
|
+
buf += c + s[i + 1];
|
|
80
|
+
i += 2;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '"') {
|
|
84
|
+
inStr = true;
|
|
85
|
+
buf += c;
|
|
86
|
+
i++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (c === '$' && s[i + 1] === '{') {
|
|
90
|
+
const close = findMatchingBrace(s, i + 2);
|
|
91
|
+
if (close === -1) {
|
|
92
|
+
buf += c;
|
|
93
|
+
i++;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
buf += s.slice(i, close + 1);
|
|
97
|
+
i = close + 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (c === ',') {
|
|
101
|
+
out.push(buf);
|
|
102
|
+
buf = '';
|
|
103
|
+
i++;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
buf += c;
|
|
107
|
+
i++;
|
|
108
|
+
}
|
|
109
|
+
out.push(buf);
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
function trim(s) {
|
|
113
|
+
let a = 0;
|
|
114
|
+
let b = s.length;
|
|
115
|
+
while (a < b && (s[a] === ' ' || s[a] === '\t'))
|
|
116
|
+
a++;
|
|
117
|
+
while (b > a && (s[b - 1] === ' ' || s[b - 1] === '\t'))
|
|
118
|
+
b--;
|
|
119
|
+
return s.slice(a, b);
|
|
120
|
+
}
|
|
121
|
+
function parseFlowElement(raw) {
|
|
122
|
+
const t = trim(raw);
|
|
123
|
+
// A whole-element scalar literal `"..."` → coerce (quotes come off, `${}`
|
|
124
|
+
// inside stays literal). Otherwise the lexer sees everything.
|
|
125
|
+
if (isFullyQuoted(t))
|
|
126
|
+
return coerce(t);
|
|
127
|
+
const ph = scanInterpolations(t);
|
|
128
|
+
if (ph.length > 0 || hasOptOutEscape(t)) {
|
|
129
|
+
return { raw: t, placeholders: ph };
|
|
130
|
+
}
|
|
131
|
+
return coerce(unescape(t, { flow: true }));
|
|
132
|
+
}
|
|
133
|
+
// ---------- Public value parsers ----------
|
|
134
|
+
/**
|
|
135
|
+
* Parse the raw text after an attribute's `:` (with surrounding ws stripped
|
|
136
|
+
* by the caller — single optional space after `:` already consumed; trailing
|
|
137
|
+
* trimmed). Returns a coerced scalar, a list (flow-literal), or an
|
|
138
|
+
* InterpolatedValue when `${}` is present.
|
|
139
|
+
*/
|
|
140
|
+
export function parseAttributeValue(raw, sigil, line, key) {
|
|
141
|
+
// Trim trailing whitespace (incl. \r left after split).
|
|
142
|
+
const value = trimTrailingWs(raw);
|
|
143
|
+
// Flow-literal trigger: sigil + `[` at the very start.
|
|
144
|
+
if (value.length >= 2 && value[0] === sigil && value[1] === '[') {
|
|
145
|
+
if (value[value.length - 1] !== ']') {
|
|
146
|
+
// Malformed — fall through to regular parsing.
|
|
147
|
+
}
|
|
148
|
+
else {
|
|
149
|
+
const inner = value.slice(2, value.length - 1);
|
|
150
|
+
if (trim(inner).length === 0)
|
|
151
|
+
return [];
|
|
152
|
+
return splitFlowElements(inner).map(parseFlowElement);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// A fully quoted value is a scalar literal: coerce strips the quotes and the
|
|
156
|
+
// `${}` inside stays literal. Partial quoting (JSON `{"m":"${x}"}`) is ordinary
|
|
157
|
+
// text — the lexer sees the `${}` and the value becomes an InterpolatedValue.
|
|
158
|
+
if (isFullyQuoted(value))
|
|
159
|
+
return coerce(value);
|
|
160
|
+
const ph = scanInterpolations(value);
|
|
161
|
+
if (ph.length > 0 || hasOptOutEscape(value)) {
|
|
162
|
+
// JSON5 detection waits until after resolution (§7.2): `{model: '${m}'}`
|
|
163
|
+
// becomes an object once the substitution has happened, not here.
|
|
164
|
+
return { raw: value, placeholders: ph };
|
|
165
|
+
}
|
|
166
|
+
// JSON5 object (§3) — tested on the RAW value, before unescape: otherwise
|
|
167
|
+
// `\{literal}` would unfold to `{literal}` and be caught by the detection.
|
|
168
|
+
if (isJson5Shaped(value))
|
|
169
|
+
return parseJson5Object(value, line, key);
|
|
170
|
+
return coerce(unescape(value, { flow: false }));
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Parse a body string. Body is raw text; we only recognise `${}` and unfold
|
|
174
|
+
* escapes (§2.5 says escapes act in body too).
|
|
175
|
+
*/
|
|
176
|
+
export function parseBodyValue(raw) {
|
|
177
|
+
const ph = scanInterpolations(raw);
|
|
178
|
+
if (ph.length > 0 || hasOptOutEscape(raw)) {
|
|
179
|
+
return { raw, placeholders: ph };
|
|
180
|
+
}
|
|
181
|
+
return unescape(raw, { flow: false });
|
|
182
|
+
}
|
|
183
|
+
function trimTrailingWs(s) {
|
|
184
|
+
let end = s.length;
|
|
185
|
+
while (end > 0) {
|
|
186
|
+
const c = s[end - 1];
|
|
187
|
+
if (c === ' ' || c === '\t' || c === '\r')
|
|
188
|
+
end--;
|
|
189
|
+
else
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
return s.slice(0, end);
|
|
193
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@notrealstudio/nr-md",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Markdown as structured data: headings become blocks, $key: value lines become attributes. Parser and serializer for the mdd/mdz block grammar.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"markdown",
|
|
7
|
+
"parser",
|
|
8
|
+
"serializer",
|
|
9
|
+
"structured-data",
|
|
10
|
+
"ast",
|
|
11
|
+
"config",
|
|
12
|
+
"frontmatter",
|
|
13
|
+
"mdd",
|
|
14
|
+
"mdz",
|
|
15
|
+
"json5"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"author": "Julian Garrett",
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"types": "./dist/index.d.ts",
|
|
22
|
+
"exports": {
|
|
23
|
+
".": {
|
|
24
|
+
"types": "./dist/index.d.ts",
|
|
25
|
+
"import": "./dist/index.js",
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
},
|
|
28
|
+
"./schema": {
|
|
29
|
+
"types": "./dist/schema.d.ts",
|
|
30
|
+
"import": "./dist/schema.js",
|
|
31
|
+
"default": "./dist/schema.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"build": "tsc",
|
|
36
|
+
"test": "vitest run",
|
|
37
|
+
"test:watch": "vitest"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"@notrealstudio/nr-json5": "file:../nr-json5"
|
|
41
|
+
},
|
|
42
|
+
"devDependencies": {
|
|
43
|
+
"@types/node": "^20.0.0",
|
|
44
|
+
"typescript": "^5.3.0",
|
|
45
|
+
"vitest": "^2.0.0"
|
|
46
|
+
},
|
|
47
|
+
"files": [
|
|
48
|
+
"dist",
|
|
49
|
+
"README.md",
|
|
50
|
+
"LICENSE"
|
|
51
|
+
],
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "git+https://github.com/Not-Real-Studio/nr-md.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/Not-Real-Studio/nr-md#readme",
|
|
57
|
+
"bugs": {
|
|
58
|
+
"url": "https://github.com/Not-Real-Studio/nr-md/issues"
|
|
59
|
+
},
|
|
60
|
+
"peerDependencies": {
|
|
61
|
+
"ajv": "^8.12.0"
|
|
62
|
+
},
|
|
63
|
+
"peerDependenciesMeta": {
|
|
64
|
+
"ajv": {
|
|
65
|
+
"optional": true
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|