@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,83 @@
|
|
|
1
|
+
import type { AttributeValue, Document, Scalar, SerializeOptions, Sigil } from './types.js';
|
|
2
|
+
import { type TypedColumn } from './typed-header.js';
|
|
3
|
+
/**
|
|
4
|
+
* Serialize a single attribute value to its canonical text form.
|
|
5
|
+
*
|
|
6
|
+
* - scalars → coerced literal with reverse escaping (§1.5)
|
|
7
|
+
* - list (flow-literal) → `sigil[a, b, c]`
|
|
8
|
+
* - InterpolatedValue → its raw text (placeholders and `\${` opt-out restored)
|
|
9
|
+
* - JSON5 object → canonical relaxed JSON5 on one line (json5-scalar-spec §5)
|
|
10
|
+
*/
|
|
11
|
+
export declare function serializeValue(value: AttributeValue, sigil?: Sigil): string;
|
|
12
|
+
/**
|
|
13
|
+
* Serialize a Document back to mdd/mdz text (serialize-spec §1).
|
|
14
|
+
*
|
|
15
|
+
* Canonical output (§1.4): one space after `:`, blocks/attributes in Document
|
|
16
|
+
* order, minimal-but-sufficient escaping, no explicit block closings (the parser
|
|
17
|
+
* derives nesting from header levels). Block-closing tokens (`## $@`) are NOT
|
|
18
|
+
* emitted (§1.3 rule 5).
|
|
19
|
+
*/
|
|
20
|
+
export declare function serialize(doc: Document, opts?: SerializeOptions): string;
|
|
21
|
+
export interface TableSerializeOptions {
|
|
22
|
+
/** Explicit column order; defaults to the keys of the first record. */
|
|
23
|
+
columns?: string[];
|
|
24
|
+
/** Line ending. Default `\n`. */
|
|
25
|
+
eol?: '\n' | '\r\n';
|
|
26
|
+
}
|
|
27
|
+
type Record_ = {
|
|
28
|
+
[k: string]: Scalar;
|
|
29
|
+
};
|
|
30
|
+
/** A typed table record — a list-typed column carries an array cell. */
|
|
31
|
+
export type TableRecord = {
|
|
32
|
+
[k: string]: Scalar | Scalar[];
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Serialize an array of flat records to a tbl table (serialize-spec §2 / §5).
|
|
36
|
+
*
|
|
37
|
+
* Header from the keys of the first record (or `opts.columns`), then one line
|
|
38
|
+
* per record with cells joined by ` | `. Cells carrying `|`, `//`, quotes, edge
|
|
39
|
+
* whitespace, or that would coerce to another type are quoted (open item §2.1).
|
|
40
|
+
*/
|
|
41
|
+
export declare function serializeTable(records: Record_[], opts?: TableSerializeOptions): string;
|
|
42
|
+
/**
|
|
43
|
+
* Serialize records to a tbl table with a TYPED header (serialize-spec §2.3).
|
|
44
|
+
*
|
|
45
|
+
* The header emits `name[:type][!]` annotations (with `\:` escaping); list-typed
|
|
46
|
+
* cells are joined by their separator. Round-trips with {@link parseTableTyped}.
|
|
47
|
+
*/
|
|
48
|
+
export declare function serializeTypedTable(records: TableRecord[], columns: TypedColumn[], opts?: {
|
|
49
|
+
eol?: '\n' | '\r\n';
|
|
50
|
+
}): string;
|
|
51
|
+
/**
|
|
52
|
+
* Parse a tbl table into typed columns + RAW (uncoerced) string cells.
|
|
53
|
+
*
|
|
54
|
+
* Used by the schema layer, where the external schema — not the inline header —
|
|
55
|
+
* drives coercion (priority: external schema > inline types > per-cell §3).
|
|
56
|
+
* Header names still honour `\:` / quoting via {@link parseHeaderCell}.
|
|
57
|
+
*/
|
|
58
|
+
export declare function parseTableRows(text: string): {
|
|
59
|
+
columns: TypedColumn[];
|
|
60
|
+
rows: string[][];
|
|
61
|
+
};
|
|
62
|
+
/**
|
|
63
|
+
* Parse a tbl table into its typed columns + records (serialize-spec §2.3).
|
|
64
|
+
*
|
|
65
|
+
* The header cells are parsed as `name[:type][!]` annotations; typed columns are
|
|
66
|
+
* coerced homogeneously and raise {@link TableParseError} on impossible values
|
|
67
|
+
* or empty required cells. Un-annotated headers behave exactly like the legacy
|
|
68
|
+
* per-cell coercion (§3) — full backward compatibility.
|
|
69
|
+
*/
|
|
70
|
+
export declare function parseTableTyped(text: string): {
|
|
71
|
+
columns: TypedColumn[];
|
|
72
|
+
records: TableRecord[];
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Parse a tbl table back into records (inverse of {@link serializeTable}, §5).
|
|
76
|
+
*
|
|
77
|
+
* First significant line is the header; each later significant line is a record.
|
|
78
|
+
* Blank lines, decorative lines (`#`/`*`/`` ` ``) and `//` comments are ignored
|
|
79
|
+
* (§5.2). Cells are trimmed and coerced (§3); typed headers (§2.3) coerce
|
|
80
|
+
* homogeneously and throw on bad cells.
|
|
81
|
+
*/
|
|
82
|
+
export declare function parseTable(text: string): TableRecord[];
|
|
83
|
+
export {};
|
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
// Serialization — Document → text (serialize-spec §1) and tbl (serialize-spec §2).
|
|
2
|
+
//
|
|
3
|
+
// Mirror image of the parser (§1–§7 of format-spec): walk the Document and emit
|
|
4
|
+
// canonical text such that `parse(serialize(doc)) ≡ doc` and
|
|
5
|
+
// `serialize(parse(text))` is idempotent (serialize-spec §1.2).
|
|
6
|
+
//
|
|
7
|
+
// Portable: string primitives only, no regex (matches the parser's discipline).
|
|
8
|
+
// Schema serialization (serialize-spec §3) is a SEPARATE task — not here.
|
|
9
|
+
import { coerce } from './coerce.js';
|
|
10
|
+
import { isJson5Object, isJson5Shaped } from './json5-value.js';
|
|
11
|
+
import { stringifyJson5 } from '@notrealstudio/nr-json5';
|
|
12
|
+
import { parseHeaderCell, emitHeaderCell, coerceTyped, } from './typed-header.js';
|
|
13
|
+
// ---------- Small string helpers (no regex) ----------
|
|
14
|
+
function hasChar(s, ch) {
|
|
15
|
+
for (let i = 0; i < s.length; i++)
|
|
16
|
+
if (s[i] === ch)
|
|
17
|
+
return true;
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
function hasSubstr(s, sub) {
|
|
21
|
+
return s.indexOf(sub) !== -1;
|
|
22
|
+
}
|
|
23
|
+
/** Trim leading/trailing spaces and tabs (matches the parser's flow trim). */
|
|
24
|
+
function trimWs(s) {
|
|
25
|
+
let a = 0;
|
|
26
|
+
let b = s.length;
|
|
27
|
+
while (a < b && (s[a] === ' ' || s[a] === '\t'))
|
|
28
|
+
a++;
|
|
29
|
+
while (b > a && (s[b - 1] === ' ' || s[b - 1] === '\t'))
|
|
30
|
+
b--;
|
|
31
|
+
return s.slice(a, b);
|
|
32
|
+
}
|
|
33
|
+
/** `coerce(s)` returns the same string → the bare value re-parses unchanged. */
|
|
34
|
+
function coercesToSame(s) {
|
|
35
|
+
const c = coerce(s);
|
|
36
|
+
return typeof c === 'string' && c === s;
|
|
37
|
+
}
|
|
38
|
+
/** Decorative line prefix (§5.2 / §6.2): `#`, `*`, `` ` ``. */
|
|
39
|
+
function startsDecorative(s) {
|
|
40
|
+
const c = s[0];
|
|
41
|
+
return c === '#' || c === '*' || c === '`';
|
|
42
|
+
}
|
|
43
|
+
// ---------- Scalar escaping (serialize-spec §1.5) ----------
|
|
44
|
+
/**
|
|
45
|
+
* Quote a string scalar: wrap in `"` and escape the minimal set understood by
|
|
46
|
+
* `unescapeQuoted` (`\\`, `\"`, real newline → `\n`, real tab → `\t`). Inside
|
|
47
|
+
* quotes the parser skips placeholder/flow recognition, so this also neutralises
|
|
48
|
+
* `${`, leading sigils and commas.
|
|
49
|
+
*/
|
|
50
|
+
function quoteScalar(s) {
|
|
51
|
+
let out = '"';
|
|
52
|
+
for (let i = 0; i < s.length; i++) {
|
|
53
|
+
const c = s[i];
|
|
54
|
+
if (c === '\\')
|
|
55
|
+
out += '\\\\';
|
|
56
|
+
else if (c === '"')
|
|
57
|
+
out += '\\"';
|
|
58
|
+
else if (c === '\n')
|
|
59
|
+
out += '\\n';
|
|
60
|
+
else if (c === '\t')
|
|
61
|
+
out += '\\t';
|
|
62
|
+
else
|
|
63
|
+
out += c;
|
|
64
|
+
}
|
|
65
|
+
return out + '"';
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Reverse of §2.5 escapes for a bare scalar: double backslashes, turn a literal
|
|
69
|
+
* `${` into the opt-out `\${`, escape a leading `sigil[` so it is not read back
|
|
70
|
+
* as a flow-literal, and escape a leading `{` of a fully brace-wrapped string so
|
|
71
|
+
* it is not read back as a JSON5 object (§3).
|
|
72
|
+
*/
|
|
73
|
+
function escapeBareScalar(s, sigil) {
|
|
74
|
+
let out = '';
|
|
75
|
+
let i = 0;
|
|
76
|
+
while (i < s.length) {
|
|
77
|
+
const c = s[i];
|
|
78
|
+
if (c === '\\') {
|
|
79
|
+
out += '\\\\';
|
|
80
|
+
i++;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (c === '$' && s[i + 1] === '{') {
|
|
84
|
+
out += '\\${';
|
|
85
|
+
i += 2;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
out += c;
|
|
89
|
+
i++;
|
|
90
|
+
}
|
|
91
|
+
if (out.length >= 2 && out[0] === sigil && out[1] === '[') {
|
|
92
|
+
out = '\\' + out;
|
|
93
|
+
}
|
|
94
|
+
// A string that looks entirely like `{...}` would read back as an object (§3);
|
|
95
|
+
// escape the leading `{`, the same idiom as `sigil[` above.
|
|
96
|
+
if (isJson5Shaped(out)) {
|
|
97
|
+
out = '\\' + out;
|
|
98
|
+
}
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Would a bare emission of this string scalar re-parse as a different value
|
|
103
|
+
* (different type, lost whitespace, an accidental placeholder)? Then quote it.
|
|
104
|
+
*/
|
|
105
|
+
function scalarNeedsQuote(s) {
|
|
106
|
+
if (s === '')
|
|
107
|
+
return false; // bare empty → `$k:` → "" already
|
|
108
|
+
if (!coercesToSame(s))
|
|
109
|
+
return true; // would coerce to number/bool/null or strip quotes
|
|
110
|
+
if (s !== trimWs(s))
|
|
111
|
+
return true; // leading/trailing ws is dropped by the parser
|
|
112
|
+
if (hasChar(s, '\n') || hasChar(s, '\t'))
|
|
113
|
+
return true; // multiline impossible bare
|
|
114
|
+
if (hasSubstr(s, '${'))
|
|
115
|
+
return true; // a literal `${` would become a placeholder
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
function serializeScalarString(s, sigil) {
|
|
119
|
+
if (scalarNeedsQuote(s))
|
|
120
|
+
return quoteScalar(s);
|
|
121
|
+
return escapeBareScalar(s, sigil);
|
|
122
|
+
}
|
|
123
|
+
function serializeScalar(v, sigil) {
|
|
124
|
+
if (v === null)
|
|
125
|
+
return 'null';
|
|
126
|
+
if (typeof v === 'boolean')
|
|
127
|
+
return v ? 'true' : 'false';
|
|
128
|
+
if (typeof v === 'number')
|
|
129
|
+
return String(v);
|
|
130
|
+
return serializeScalarString(v, sigil);
|
|
131
|
+
}
|
|
132
|
+
// ---------- Flow-literal lists (§4) ----------
|
|
133
|
+
/** A flow element is quoted when bare emission would change it or break splitting. */
|
|
134
|
+
function flowElementNeedsQuote(s) {
|
|
135
|
+
if (s === '')
|
|
136
|
+
return true; // empty element must be `""` (≠ empty list)
|
|
137
|
+
if (!coercesToSame(s))
|
|
138
|
+
return true;
|
|
139
|
+
if (s !== trimWs(s))
|
|
140
|
+
return true; // elements are trimmed on parse
|
|
141
|
+
if (hasChar(s, '\n') || hasChar(s, '\t'))
|
|
142
|
+
return true;
|
|
143
|
+
if (hasChar(s, ','))
|
|
144
|
+
return true; // separator
|
|
145
|
+
if (hasChar(s, '"'))
|
|
146
|
+
return true;
|
|
147
|
+
if (hasChar(s, '\\'))
|
|
148
|
+
return true; // would be eaten by the element unescape
|
|
149
|
+
if (hasSubstr(s, '${'))
|
|
150
|
+
return true;
|
|
151
|
+
return false;
|
|
152
|
+
}
|
|
153
|
+
function serializeListItem(it, sigil) {
|
|
154
|
+
if (it === null)
|
|
155
|
+
return 'null';
|
|
156
|
+
if (typeof it === 'boolean')
|
|
157
|
+
return it ? 'true' : 'false';
|
|
158
|
+
if (typeof it === 'number')
|
|
159
|
+
return String(it);
|
|
160
|
+
if (typeof it === 'string') {
|
|
161
|
+
return flowElementNeedsQuote(it) ? quoteScalar(it) : it;
|
|
162
|
+
}
|
|
163
|
+
// InterpolatedValue — raw already carries `${…}` / `\${`. The flow splitter is
|
|
164
|
+
// brace- and quote-aware, so the raw segment survives intact.
|
|
165
|
+
return it.raw;
|
|
166
|
+
}
|
|
167
|
+
function serializeFlowList(items, sigil) {
|
|
168
|
+
const parts = items.map((it) => serializeListItem(it, sigil));
|
|
169
|
+
return sigil + '[' + parts.join(', ') + ']';
|
|
170
|
+
}
|
|
171
|
+
// ---------- Public value serializer (serialize-spec §1.3) ----------
|
|
172
|
+
/**
|
|
173
|
+
* Serialize a single attribute value to its canonical text form.
|
|
174
|
+
*
|
|
175
|
+
* - scalars → coerced literal with reverse escaping (§1.5)
|
|
176
|
+
* - list (flow-literal) → `sigil[a, b, c]`
|
|
177
|
+
* - InterpolatedValue → its raw text (placeholders and `\${` opt-out restored)
|
|
178
|
+
* - JSON5 object → canonical relaxed JSON5 on one line (json5-scalar-spec §5)
|
|
179
|
+
*/
|
|
180
|
+
export function serializeValue(value, sigil = '$') {
|
|
181
|
+
if (Array.isArray(value))
|
|
182
|
+
return serializeFlowList(value, sigil);
|
|
183
|
+
if (value !== null && typeof value === 'object') {
|
|
184
|
+
// JSON5 object vs InterpolatedValue: the guard rejects the {raw, placeholders}
|
|
185
|
+
// shape, which Json5Object's index signature cannot tell apart on its own.
|
|
186
|
+
if (isJson5Object(value))
|
|
187
|
+
return stringifyJson5(value);
|
|
188
|
+
return value.raw; // InterpolatedValue
|
|
189
|
+
}
|
|
190
|
+
return serializeScalar(value, sigil);
|
|
191
|
+
}
|
|
192
|
+
// ---------- Body (§2.3, §6) ----------
|
|
193
|
+
function isNameStart(c) {
|
|
194
|
+
return (c >= 65 && c <= 90) || (c >= 97 && c <= 122) || c === 95; /* _ */
|
|
195
|
+
}
|
|
196
|
+
function isBlockNameCont(c) {
|
|
197
|
+
return (isNameStart(c) ||
|
|
198
|
+
(c >= 48 && c <= 57) ||
|
|
199
|
+
c === 45 /* - */ ||
|
|
200
|
+
c === 46 /* . */);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Offset of the sigil that would make the parser read a body line as STRUCTURE
|
|
204
|
+
* (an attribute §2.2 or a header §2.1), or -1. What gets escaped is the sigil
|
|
205
|
+
* itself (`\$`), not the first character: the parser already unfolds `\$` (§2.5),
|
|
206
|
+
* and we introduce no new escapes (`\#`, `\%`). A `%role` marker is not structure
|
|
207
|
+
* at the document level — it belongs to the message splitter — so it is left alone.
|
|
208
|
+
*/
|
|
209
|
+
function structuralSigilPos(line, sigil) {
|
|
210
|
+
// Attribute: sigil + name-start + name-cont* + ':'
|
|
211
|
+
if (line[0] === sigil && isNameStart(line.charCodeAt(1))) {
|
|
212
|
+
let i = 2;
|
|
213
|
+
while (i < line.length && isBlockNameCont(line.charCodeAt(i)))
|
|
214
|
+
i++;
|
|
215
|
+
if (line[i] === ':')
|
|
216
|
+
return 0;
|
|
217
|
+
}
|
|
218
|
+
// Header: #{1..6} + ' ' + sigil (a name, or a closing token)
|
|
219
|
+
let n = 0;
|
|
220
|
+
while (n < line.length && line[n] === '#')
|
|
221
|
+
n++;
|
|
222
|
+
if (n >= 1 && n <= 6 && line[n] === ' ' && line[n + 1] === sigil)
|
|
223
|
+
return n + 1;
|
|
224
|
+
return -1;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Reverse §2.5 for a plain body string: double the backslashes, and escape the
|
|
228
|
+
* sigil on any line that would re-parse as an attribute or a header.
|
|
229
|
+
* Round-trip holds because the parser unfolds `\$` exactly once.
|
|
230
|
+
*/
|
|
231
|
+
function escapeBodyString(s, sigil) {
|
|
232
|
+
return s
|
|
233
|
+
.split('\n')
|
|
234
|
+
.map((line) => {
|
|
235
|
+
let esc = '';
|
|
236
|
+
for (let i = 0; i < line.length; i++)
|
|
237
|
+
esc += line[i] === '\\' ? '\\\\' : line[i];
|
|
238
|
+
const pos = structuralSigilPos(line, sigil);
|
|
239
|
+
// The prefix [0..pos) holds no `\` (only sigil / `#` / space), so an index
|
|
240
|
+
// into esc is the same as an index into line.
|
|
241
|
+
return pos === -1 ? esc : esc.slice(0, pos) + '\\' + esc.slice(pos);
|
|
242
|
+
})
|
|
243
|
+
.join('\n');
|
|
244
|
+
}
|
|
245
|
+
function serializeBody(body, sigil) {
|
|
246
|
+
if (typeof body === 'string')
|
|
247
|
+
return escapeBodyString(body, sigil);
|
|
248
|
+
return body.raw; // InterpolatedValue — emit raw verbatim
|
|
249
|
+
}
|
|
250
|
+
// ---------- Blocks (§2.1, §2.2) ----------
|
|
251
|
+
function serializeAttribute(attr, sigil) {
|
|
252
|
+
const key = attr.key.join('.');
|
|
253
|
+
const v = serializeValue(attr.value, sigil);
|
|
254
|
+
return v.length > 0 ? `${sigil}${key}: ${v}` : `${sigil}${key}:`;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Header id survives bare emission unless it carries edge whitespace, is empty,
|
|
258
|
+
* or would itself read back as a quoted literal (§2.1, NOT-236). Then — quotes.
|
|
259
|
+
*/
|
|
260
|
+
function idNeedsQuote(s) {
|
|
261
|
+
if (s === '')
|
|
262
|
+
return true;
|
|
263
|
+
if (s !== trimWs(s))
|
|
264
|
+
return true;
|
|
265
|
+
if (hasChar(s, '\n'))
|
|
266
|
+
return true;
|
|
267
|
+
if (s[0] === '"')
|
|
268
|
+
return true;
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
function serializeId(id) {
|
|
272
|
+
return idNeedsQuote(id) ? quoteScalar(id) : id;
|
|
273
|
+
}
|
|
274
|
+
function emitBlock(block, sigil, baseLevel, out) {
|
|
275
|
+
const hashes = '#'.repeat(block.level + baseLevel - 1);
|
|
276
|
+
let header = `${hashes} ${sigil}${block.name}`;
|
|
277
|
+
if (block.id !== undefined)
|
|
278
|
+
header += ' ' + serializeId(block.id);
|
|
279
|
+
out.push(header);
|
|
280
|
+
for (const attr of block.attrs)
|
|
281
|
+
out.push(serializeAttribute(attr, sigil));
|
|
282
|
+
if (block.body !== undefined)
|
|
283
|
+
out.push(serializeBody(block.body, sigil));
|
|
284
|
+
for (const child of block.children)
|
|
285
|
+
emitBlock(child, sigil, baseLevel, out);
|
|
286
|
+
}
|
|
287
|
+
// ---------- serialize (serialize-spec §1.1) ----------
|
|
288
|
+
/**
|
|
289
|
+
* Serialize a Document back to mdd/mdz text (serialize-spec §1).
|
|
290
|
+
*
|
|
291
|
+
* Canonical output (§1.4): one space after `:`, blocks/attributes in Document
|
|
292
|
+
* order, minimal-but-sufficient escaping, no explicit block closings (the parser
|
|
293
|
+
* derives nesting from header levels). Block-closing tokens (`## $@`) are NOT
|
|
294
|
+
* emitted (§1.3 rule 5).
|
|
295
|
+
*/
|
|
296
|
+
export function serialize(doc, opts = {}) {
|
|
297
|
+
const sigil = opts.sigil ?? doc.sigil;
|
|
298
|
+
const baseLevel = opts.baseLevel ?? 1;
|
|
299
|
+
if (baseLevel < 1 || baseLevel > 6) {
|
|
300
|
+
throw new Error(`baseLevel must be in 1..6, got ${baseLevel}`);
|
|
301
|
+
}
|
|
302
|
+
const eol = opts.eol ?? '\n';
|
|
303
|
+
const out = [];
|
|
304
|
+
const root = doc.root;
|
|
305
|
+
// Root (level 0) has no header — emit its global attributes and body, then
|
|
306
|
+
// the top-level blocks.
|
|
307
|
+
for (const attr of root.attrs)
|
|
308
|
+
out.push(serializeAttribute(attr, sigil));
|
|
309
|
+
if (root.body !== undefined)
|
|
310
|
+
out.push(serializeBody(root.body, sigil));
|
|
311
|
+
for (const child of root.children)
|
|
312
|
+
emitBlock(child, sigil, baseLevel, out);
|
|
313
|
+
const text = out.join('\n');
|
|
314
|
+
return eol === '\n' ? text : text.split('\n').join(eol);
|
|
315
|
+
}
|
|
316
|
+
/** A header/cell is quoted when bare emission would change it or break a column. */
|
|
317
|
+
function cellNeedsQuote(s) {
|
|
318
|
+
if (s === '')
|
|
319
|
+
return true;
|
|
320
|
+
if (!coercesToSame(s))
|
|
321
|
+
return true;
|
|
322
|
+
if (s !== trimWs(s))
|
|
323
|
+
return true;
|
|
324
|
+
if (hasChar(s, '|'))
|
|
325
|
+
return true; // column separator
|
|
326
|
+
if (hasSubstr(s, '//'))
|
|
327
|
+
return true; // comment marker (§5.2)
|
|
328
|
+
if (hasChar(s, '"'))
|
|
329
|
+
return true;
|
|
330
|
+
if (hasChar(s, '\n'))
|
|
331
|
+
return true;
|
|
332
|
+
if (startsDecorative(s))
|
|
333
|
+
return true; // would be skipped as a decorative line
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
function serializeCell(v) {
|
|
337
|
+
if (v === null)
|
|
338
|
+
return 'null';
|
|
339
|
+
if (typeof v === 'boolean')
|
|
340
|
+
return v ? 'true' : 'false';
|
|
341
|
+
if (typeof v === 'number')
|
|
342
|
+
return String(v);
|
|
343
|
+
return cellNeedsQuote(v) ? quoteScalar(v) : v;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Serialize an array of flat records to a tbl table (serialize-spec §2 / §5).
|
|
347
|
+
*
|
|
348
|
+
* Header from the keys of the first record (or `opts.columns`), then one line
|
|
349
|
+
* per record with cells joined by ` | `. Cells carrying `|`, `//`, quotes, edge
|
|
350
|
+
* whitespace, or that would coerce to another type are quoted (open item §2.1).
|
|
351
|
+
*/
|
|
352
|
+
export function serializeTable(records, opts = {}) {
|
|
353
|
+
const eol = opts.eol ?? '\n';
|
|
354
|
+
const columns = opts.columns ?? (records.length > 0 ? Object.keys(records[0]) : []);
|
|
355
|
+
const lines = [];
|
|
356
|
+
lines.push(columns.map((c) => serializeCell(c)).join(' | '));
|
|
357
|
+
for (const rec of records) {
|
|
358
|
+
lines.push(columns.map((c) => serializeCell(rec[c] ?? null)).join(' | '));
|
|
359
|
+
}
|
|
360
|
+
const text = lines.join('\n');
|
|
361
|
+
return eol === '\n' ? text : text.split('\n').join(eol);
|
|
362
|
+
}
|
|
363
|
+
/** A list cell needs quoting when the joined text would break the column. */
|
|
364
|
+
function listCellText(items, sep) {
|
|
365
|
+
const joined = items.map((it) => (it === null ? '' : String(it))).join(sep);
|
|
366
|
+
// Quote when the joined text carries the column separator or would re-coerce.
|
|
367
|
+
return cellNeedsQuote(joined) ? quoteScalar(joined) : joined;
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Serialize records to a tbl table with a TYPED header (serialize-spec §2.3).
|
|
371
|
+
*
|
|
372
|
+
* The header emits `name[:type][!]` annotations (with `\:` escaping); list-typed
|
|
373
|
+
* cells are joined by their separator. Round-trips with {@link parseTableTyped}.
|
|
374
|
+
*/
|
|
375
|
+
export function serializeTypedTable(records, columns, opts = {}) {
|
|
376
|
+
const eol = opts.eol ?? '\n';
|
|
377
|
+
const lines = [];
|
|
378
|
+
lines.push(columns.map((c) => emitHeaderCell(c, serializeCell)).join(' | '));
|
|
379
|
+
for (const rec of records) {
|
|
380
|
+
lines.push(columns
|
|
381
|
+
.map((c) => {
|
|
382
|
+
const v = rec[c.name];
|
|
383
|
+
if (c.type?.kind === 'list' && Array.isArray(v))
|
|
384
|
+
return listCellText(v, c.type.sep);
|
|
385
|
+
return serializeCell((v ?? null));
|
|
386
|
+
})
|
|
387
|
+
.join(' | '));
|
|
388
|
+
}
|
|
389
|
+
const text = lines.join('\n');
|
|
390
|
+
return eol === '\n' ? text : text.split('\n').join(eol);
|
|
391
|
+
}
|
|
392
|
+
// ---------- tbl parse (inverse, for round-trip; §5) ----------
|
|
393
|
+
/** Strip a `//` comment that sits outside a double-quoted region (§5.2). */
|
|
394
|
+
function stripComment(line) {
|
|
395
|
+
let i = 0;
|
|
396
|
+
let inStr = false;
|
|
397
|
+
while (i < line.length) {
|
|
398
|
+
const c = line[i];
|
|
399
|
+
if (inStr) {
|
|
400
|
+
if (c === '\\' && i + 1 < line.length) {
|
|
401
|
+
i += 2;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (c === '"')
|
|
405
|
+
inStr = false;
|
|
406
|
+
i++;
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
if (c === '"') {
|
|
410
|
+
inStr = true;
|
|
411
|
+
i++;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
if (c === '/' && line[i + 1] === '/')
|
|
415
|
+
return line.slice(0, i);
|
|
416
|
+
i++;
|
|
417
|
+
}
|
|
418
|
+
return line;
|
|
419
|
+
}
|
|
420
|
+
/** Split a table line into cells by `|`, respecting double-quoted regions. */
|
|
421
|
+
function splitCells(line) {
|
|
422
|
+
const cells = [];
|
|
423
|
+
let buf = '';
|
|
424
|
+
let i = 0;
|
|
425
|
+
let inStr = false;
|
|
426
|
+
while (i < line.length) {
|
|
427
|
+
const c = line[i];
|
|
428
|
+
if (inStr) {
|
|
429
|
+
buf += c;
|
|
430
|
+
if (c === '\\' && i + 1 < line.length) {
|
|
431
|
+
buf += line[i + 1];
|
|
432
|
+
i += 2;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (c === '"')
|
|
436
|
+
inStr = false;
|
|
437
|
+
i++;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (c === '"') {
|
|
441
|
+
inStr = true;
|
|
442
|
+
buf += c;
|
|
443
|
+
i++;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
if (c === '|') {
|
|
447
|
+
cells.push(buf);
|
|
448
|
+
buf = '';
|
|
449
|
+
i++;
|
|
450
|
+
continue;
|
|
451
|
+
}
|
|
452
|
+
buf += c;
|
|
453
|
+
i++;
|
|
454
|
+
}
|
|
455
|
+
cells.push(buf);
|
|
456
|
+
return cells;
|
|
457
|
+
}
|
|
458
|
+
/** Collect significant lines of a tbl body (drop blanks, decoratives, comments). */
|
|
459
|
+
function significantLines(text) {
|
|
460
|
+
const rawLines = text.split('\n');
|
|
461
|
+
const significant = [];
|
|
462
|
+
for (const raw of rawLines) {
|
|
463
|
+
const stripped = stripComment(raw);
|
|
464
|
+
const t = trimWs(stripped);
|
|
465
|
+
if (t.length === 0)
|
|
466
|
+
continue;
|
|
467
|
+
if (startsDecorative(t))
|
|
468
|
+
continue;
|
|
469
|
+
significant.push(stripped);
|
|
470
|
+
}
|
|
471
|
+
return significant;
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Parse a tbl table into typed columns + RAW (uncoerced) string cells.
|
|
475
|
+
*
|
|
476
|
+
* Used by the schema layer, where the external schema — not the inline header —
|
|
477
|
+
* drives coercion (priority: external schema > inline types > per-cell §3).
|
|
478
|
+
* Header names still honour `\:` / quoting via {@link parseHeaderCell}.
|
|
479
|
+
*/
|
|
480
|
+
export function parseTableRows(text) {
|
|
481
|
+
const significant = significantLines(text);
|
|
482
|
+
if (significant.length === 0)
|
|
483
|
+
return { columns: [], rows: [] };
|
|
484
|
+
const columns = splitCells(significant[0]).map(parseHeaderCell);
|
|
485
|
+
const rows = [];
|
|
486
|
+
for (let i = 1; i < significant.length; i++) {
|
|
487
|
+
rows.push(splitCells(significant[i]).map((c) => trimWs(c)));
|
|
488
|
+
}
|
|
489
|
+
return { columns, rows };
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Parse a tbl table into its typed columns + records (serialize-spec §2.3).
|
|
493
|
+
*
|
|
494
|
+
* The header cells are parsed as `name[:type][!]` annotations; typed columns are
|
|
495
|
+
* coerced homogeneously and raise {@link TableParseError} on impossible values
|
|
496
|
+
* or empty required cells. Un-annotated headers behave exactly like the legacy
|
|
497
|
+
* per-cell coercion (§3) — full backward compatibility.
|
|
498
|
+
*/
|
|
499
|
+
export function parseTableTyped(text) {
|
|
500
|
+
const significant = significantLines(text);
|
|
501
|
+
if (significant.length === 0)
|
|
502
|
+
return { columns: [], records: [] };
|
|
503
|
+
const columns = splitCells(significant[0]).map(parseHeaderCell);
|
|
504
|
+
const records = [];
|
|
505
|
+
for (let i = 1; i < significant.length; i++) {
|
|
506
|
+
const cells = splitCells(significant[i]);
|
|
507
|
+
const rec = {};
|
|
508
|
+
for (let j = 0; j < columns.length; j++) {
|
|
509
|
+
rec[columns[j].name] = coerceTyped(trimWs(cells[j] ?? ''), columns[j]);
|
|
510
|
+
}
|
|
511
|
+
records.push(rec);
|
|
512
|
+
}
|
|
513
|
+
return { columns, records };
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Parse a tbl table back into records (inverse of {@link serializeTable}, §5).
|
|
517
|
+
*
|
|
518
|
+
* First significant line is the header; each later significant line is a record.
|
|
519
|
+
* Blank lines, decorative lines (`#`/`*`/`` ` ``) and `//` comments are ignored
|
|
520
|
+
* (§5.2). Cells are trimmed and coerced (§3); typed headers (§2.3) coerce
|
|
521
|
+
* homogeneously and throw on bad cells.
|
|
522
|
+
*/
|
|
523
|
+
export function parseTable(text) {
|
|
524
|
+
return parseTableTyped(text).records;
|
|
525
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { Scalar } from './types.js';
|
|
2
|
+
export type ColumnType = {
|
|
3
|
+
kind: 'string';
|
|
4
|
+
} | {
|
|
5
|
+
kind: 'number';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'boolean';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'list';
|
|
10
|
+
sep: string;
|
|
11
|
+
};
|
|
12
|
+
export interface TypedColumn {
|
|
13
|
+
/** Column name (annotation and escapes already resolved). */
|
|
14
|
+
name: string;
|
|
15
|
+
/** Declared type, or undefined for an un-annotated (legacy) column. */
|
|
16
|
+
type?: ColumnType;
|
|
17
|
+
/** `!` marker — an empty cell in this column is an error. */
|
|
18
|
+
required: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** Loud, non-silent table-parse error (serialize-spec §6). */
|
|
21
|
+
export declare class TableParseError extends Error {
|
|
22
|
+
constructor(message: string);
|
|
23
|
+
}
|
|
24
|
+
/** Minimal JSON Schema fragment (plain JSON — no TypeBox in core). */
|
|
25
|
+
export type JSONSchema = {
|
|
26
|
+
[k: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* Parse a single header cell (`name[:type][!]`, §2.3).
|
|
30
|
+
*
|
|
31
|
+
* - A fully double-quoted cell is a literal name — no annotation.
|
|
32
|
+
* - `\:` escapes a literal colon in the name; `\\` a literal backslash.
|
|
33
|
+
* - `!` (after the optional type) marks the column required.
|
|
34
|
+
* - Un-annotated cells keep legacy behaviour (per-cell coercion §3).
|
|
35
|
+
*/
|
|
36
|
+
export declare function parseHeaderCell(cell: string): TypedColumn;
|
|
37
|
+
/** Parse a full header line into typed columns (used by {@link tableSchema}). */
|
|
38
|
+
export declare function parseHeaderLine(line: string, splitCells: (l: string) => string[]): TypedColumn[];
|
|
39
|
+
/**
|
|
40
|
+
* Emit a header cell. Typed columns emit `name[:type][!]` with `\:` escaping;
|
|
41
|
+
* un-annotated columns fall back to the caller's plain cell serializer.
|
|
42
|
+
*/
|
|
43
|
+
export declare function emitHeaderCell(col: TypedColumn, plainCell: (s: string) => string): string;
|
|
44
|
+
/**
|
|
45
|
+
* Coerce a raw cell to the column's declared type (§2.3). Un-annotated columns
|
|
46
|
+
* fall back to §3 coercion. Impossible coercions and empty required cells throw
|
|
47
|
+
* {@link TableParseError} — never a silent string.
|
|
48
|
+
*/
|
|
49
|
+
export declare function coerceTyped(raw: string, col: TypedColumn): Scalar | Scalar[];
|
|
50
|
+
/**
|
|
51
|
+
* Derive a JSON Schema fragment from a typed header (serialize-spec §2.3).
|
|
52
|
+
*
|
|
53
|
+
* `weight:number` → `{type:'number'}`, `!` → required, `list(;)` →
|
|
54
|
+
* `{type:'array', 'x-mdd':{separator:';'}}`. The result is derived, never stored.
|
|
55
|
+
*/
|
|
56
|
+
export declare function tableSchema(columns: TypedColumn[]): JSONSchema;
|