@corvidlabs/threemd 1.0.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/README.md +53 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.js +424 -0
- package/package.json +35 -0
- package/src/index.ts +809 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
// @corvidlabs/threemd
|
|
2
|
+
//
|
|
3
|
+
// A faithful TypeScript port of the Swift ThreeMD parser and serializer.
|
|
4
|
+
// The 3md format is Markdown extended along a single free Z axis: a document is
|
|
5
|
+
// a required frontmatter block, an optional Markdown preamble, and zero or more
|
|
6
|
+
// planes introduced by `@plane` directives.
|
|
7
|
+
//
|
|
8
|
+
// This module mirrors the canonical Swift implementation in Sources/ThreeMD
|
|
9
|
+
// exactly, including its error behavior. The cross-language conformance vectors
|
|
10
|
+
// in conformance/ pin that behavior down.
|
|
11
|
+
|
|
12
|
+
// MARK: - Types
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The meaning assigned to a 3md document's third (Z) axis.
|
|
16
|
+
*
|
|
17
|
+
* The value is always trimmed and lowercased, matching the Swift `Axis` type.
|
|
18
|
+
* Any string is permitted; common values are `time`, `depth`, `layer`,
|
|
19
|
+
* `frame`, and `space`.
|
|
20
|
+
*/
|
|
21
|
+
export type Axis = string;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A single slice of a 3md document positioned along the Z axis.
|
|
25
|
+
*
|
|
26
|
+
* `z` is required and orders planes along the document's axis. `x` and `y` are
|
|
27
|
+
* optional in-plane offsets. Any directive attributes other than `z`, `x`, `y`,
|
|
28
|
+
* and `label` are preserved in `attributes`.
|
|
29
|
+
*/
|
|
30
|
+
export interface Plane {
|
|
31
|
+
/** Position along the document's Z axis. */
|
|
32
|
+
readonly z: number;
|
|
33
|
+
/** Optional human-readable label, or `null` when absent. */
|
|
34
|
+
readonly label: string | null;
|
|
35
|
+
/** Optional horizontal offset within the plane, or `null` when absent. */
|
|
36
|
+
readonly x: number | null;
|
|
37
|
+
/** Optional vertical offset within the plane, or `null` when absent. */
|
|
38
|
+
readonly y: number | null;
|
|
39
|
+
/** Extra directive attributes that are not `z`, `x`, `y`, or `label`. */
|
|
40
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
41
|
+
/** The Markdown content of this plane, with surrounding blank lines trimmed. */
|
|
42
|
+
readonly body: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A reference from one plane's Markdown body to another plane by its `z`
|
|
47
|
+
* position, written `[[z=N]]` or `[[z=N|text]]`.
|
|
48
|
+
*
|
|
49
|
+
* Cross-plane links live verbatim inside plane bodies; {@link links} extracts
|
|
50
|
+
* them in document order. This mirrors the cross-language contract pinned by the
|
|
51
|
+
* `links-*.json` conformance vectors.
|
|
52
|
+
*/
|
|
53
|
+
export interface CrossPlaneLink {
|
|
54
|
+
/** The `z` position of the plane whose body contains this link. */
|
|
55
|
+
readonly sourceZ: number;
|
|
56
|
+
/** The `z` position the link points at, parsed as a finite decimal. */
|
|
57
|
+
readonly targetZ: number;
|
|
58
|
+
/** The optional link text: `null` when absent, `""` when present but empty. */
|
|
59
|
+
readonly text: string | null;
|
|
60
|
+
/** Whether a plane with `z === targetZ` exists in the document. */
|
|
61
|
+
readonly targetExists: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A summarized directed edge in a document's cross-plane link graph.
|
|
66
|
+
*
|
|
67
|
+
* Multiple `[[z=N]]` links from the same source plane to the same target plane
|
|
68
|
+
* collapse into one edge with an incremented `count`.
|
|
69
|
+
*/
|
|
70
|
+
export interface CrossPlaneLinkEdge {
|
|
71
|
+
/** The `z` position of the plane whose body contains the link. */
|
|
72
|
+
readonly sourceZ: number;
|
|
73
|
+
/** The target `z` position named by the link. */
|
|
74
|
+
readonly targetZ: number;
|
|
75
|
+
/** Whether a plane with `z === targetZ` exists in the document. */
|
|
76
|
+
readonly targetExists: boolean;
|
|
77
|
+
/** Number of links collapsed into this edge. */
|
|
78
|
+
readonly count: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A parsed 3md document: Markdown extended along one free Z axis.
|
|
83
|
+
*/
|
|
84
|
+
export interface Document {
|
|
85
|
+
/** The declared 3md format version, for example `"0.1"`. */
|
|
86
|
+
readonly version: string;
|
|
87
|
+
/** What the Z axis represents in this document (trimmed, lowercased). */
|
|
88
|
+
readonly axis: Axis;
|
|
89
|
+
/** Optional document title from the frontmatter, or `null`. */
|
|
90
|
+
readonly title: string | null;
|
|
91
|
+
/** Any frontmatter keys other than `3md`, `axis`, and `title`. */
|
|
92
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
93
|
+
/** Markdown content after the frontmatter but before the first plane, or `null`. */
|
|
94
|
+
readonly preamble: string | null;
|
|
95
|
+
/** The document's planes, in source order. */
|
|
96
|
+
readonly planes: readonly Plane[];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// MARK: - Errors
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The set of error case names a conforming 3md parser must reject with.
|
|
103
|
+
*
|
|
104
|
+
* These mirror the Swift `ParseError` enum cases one to one.
|
|
105
|
+
*/
|
|
106
|
+
export type ParseErrorCode =
|
|
107
|
+
| "missingFrontmatter"
|
|
108
|
+
| "invalidFrontmatter"
|
|
109
|
+
| "missingVersion"
|
|
110
|
+
| "invalidPlaneDirective"
|
|
111
|
+
| "missingPlanePosition"
|
|
112
|
+
| "duplicatePlane";
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* An error thrown while parsing a 3md document.
|
|
116
|
+
*
|
|
117
|
+
* The `code` property names the canonical Swift `ParseError` case, so callers
|
|
118
|
+
* and the conformance suite can match on it without parsing the message.
|
|
119
|
+
*/
|
|
120
|
+
export class ParseError extends Error {
|
|
121
|
+
/** The canonical Swift `ParseError` case name. */
|
|
122
|
+
public readonly code: ParseErrorCode;
|
|
123
|
+
/** Optional 1-based line number for directive-related errors. */
|
|
124
|
+
public readonly line: number | undefined;
|
|
125
|
+
/** Optional human-readable detail string. */
|
|
126
|
+
public readonly detail: string | undefined;
|
|
127
|
+
|
|
128
|
+
public constructor(code: ParseErrorCode, message: string, line?: number, detail?: string) {
|
|
129
|
+
super(message);
|
|
130
|
+
this.name = "ParseError";
|
|
131
|
+
this.code = code;
|
|
132
|
+
this.line = line;
|
|
133
|
+
this.detail = detail;
|
|
134
|
+
Object.setPrototypeOf(this, ParseError.prototype);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// MARK: - Lexing helpers
|
|
139
|
+
|
|
140
|
+
const RESERVED_PLANE_KEYS: ReadonlySet<string> = new Set(["z", "x", "y", "label"]);
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Trims leading and trailing space and tab characters, mirroring the Swift
|
|
144
|
+
* `trimmingCharacters(in: .whitespaces)` used throughout the parser. Lines are
|
|
145
|
+
* already split on newlines, so only horizontal whitespace is relevant.
|
|
146
|
+
*/
|
|
147
|
+
function trimWhitespace(value: string): string {
|
|
148
|
+
return value.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Returns the first space/tab-delimited token of a line, or the empty string.
|
|
153
|
+
* Mirrors the Swift `firstToken(of:)` helper.
|
|
154
|
+
*/
|
|
155
|
+
function firstToken(line: string): string {
|
|
156
|
+
for (const token of line.split(/[ \t]+/)) {
|
|
157
|
+
if (token.length > 0) {
|
|
158
|
+
return token;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Splits a directive remainder into tokens, keeping single- or double-quoted
|
|
166
|
+
* spans intact. A backslash inside a quoted span escapes the next character, so
|
|
167
|
+
* `\"` does not close a double-quoted value. Throws on an unterminated quote.
|
|
168
|
+
* Mirrors the Swift `tokenize(_:line:)` helper.
|
|
169
|
+
*/
|
|
170
|
+
function tokenize(input: string, line: number): string[] {
|
|
171
|
+
const tokens: string[] = [];
|
|
172
|
+
let current = "";
|
|
173
|
+
let activeQuote: string | null = null;
|
|
174
|
+
let escaped = false;
|
|
175
|
+
|
|
176
|
+
for (const character of input) {
|
|
177
|
+
if (activeQuote !== null) {
|
|
178
|
+
current += character;
|
|
179
|
+
if (escaped) {
|
|
180
|
+
escaped = false;
|
|
181
|
+
} else if (character === "\\") {
|
|
182
|
+
escaped = true;
|
|
183
|
+
} else if (character === activeQuote) {
|
|
184
|
+
activeQuote = null;
|
|
185
|
+
}
|
|
186
|
+
} else if (character === '"' || character === "'") {
|
|
187
|
+
activeQuote = character;
|
|
188
|
+
current += character;
|
|
189
|
+
} else if (character === " " || character === "\t") {
|
|
190
|
+
if (current.length > 0) {
|
|
191
|
+
tokens.push(current);
|
|
192
|
+
current = "";
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
current += character;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (activeQuote !== null) {
|
|
199
|
+
throw new ParseError(
|
|
200
|
+
"invalidPlaneDirective",
|
|
201
|
+
`Invalid @plane directive on line ${line}: unterminated quote in '${input}'`,
|
|
202
|
+
line,
|
|
203
|
+
`unterminated quote in '${input}'`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (current.length > 0) {
|
|
207
|
+
tokens.push(current);
|
|
208
|
+
}
|
|
209
|
+
return tokens;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Strips a single pair of matching surrounding quotes and reverses backslash
|
|
214
|
+
* escaping (`\\` and `\"`). Mirrors the Swift `unquote(_:)` helper.
|
|
215
|
+
*/
|
|
216
|
+
function unquote(value: string): string {
|
|
217
|
+
if (value.length < 2) {
|
|
218
|
+
return value;
|
|
219
|
+
}
|
|
220
|
+
const first = value[0];
|
|
221
|
+
const last = value[value.length - 1];
|
|
222
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
223
|
+
return unescapeValue(value.slice(1, -1));
|
|
224
|
+
}
|
|
225
|
+
return value;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Reverses the serializer's backslash escaping, so `\\` becomes `\` and `\"`
|
|
230
|
+
* becomes `"`. Any other escaped character yields the character itself.
|
|
231
|
+
*/
|
|
232
|
+
function unescapeValue(value: string): string {
|
|
233
|
+
let result = "";
|
|
234
|
+
let escaping = false;
|
|
235
|
+
for (const character of value) {
|
|
236
|
+
if (escaping) {
|
|
237
|
+
result += character;
|
|
238
|
+
escaping = false;
|
|
239
|
+
} else if (character === "\\") {
|
|
240
|
+
escaping = true;
|
|
241
|
+
} else {
|
|
242
|
+
result += character;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (escaping) {
|
|
246
|
+
result += "\\";
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Returns the fence character if a trimmed line opens a fenced code block
|
|
253
|
+
* (three or more backticks or tildes), otherwise `null`.
|
|
254
|
+
*/
|
|
255
|
+
function fenceCharacter(trimmed: string): string | null {
|
|
256
|
+
if (trimmed.startsWith("```")) {
|
|
257
|
+
return "`";
|
|
258
|
+
}
|
|
259
|
+
if (trimmed.startsWith("~~~")) {
|
|
260
|
+
return "~";
|
|
261
|
+
}
|
|
262
|
+
return null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Joins body lines, dropping leading and trailing blank lines. Returns `null`
|
|
267
|
+
* when nothing but whitespace remains. Mirrors the Swift `collapse(_:)` helper,
|
|
268
|
+
* which returns an optional.
|
|
269
|
+
*/
|
|
270
|
+
function collapse(lines: readonly string[]): string | null {
|
|
271
|
+
let start = 0;
|
|
272
|
+
let end = lines.length;
|
|
273
|
+
while (start < end && trimWhitespace(lines[start] ?? "") === "") {
|
|
274
|
+
start += 1;
|
|
275
|
+
}
|
|
276
|
+
while (end > start && trimWhitespace(lines[end - 1] ?? "") === "") {
|
|
277
|
+
end -= 1;
|
|
278
|
+
}
|
|
279
|
+
if (start >= end) {
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
return lines.slice(start, end).join("\n");
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Parses a finite decimal number for `z`/`x`/`y`: optional sign, digits with an
|
|
287
|
+
* optional fraction, and an optional exponent. Hex, `inf`, `nan`, and values
|
|
288
|
+
* that overflow to infinity are rejected, so the Swift and TypeScript parsers
|
|
289
|
+
* agree on the numeric grammar.
|
|
290
|
+
*
|
|
291
|
+
* @param text The already trimmed, unquoted attribute value.
|
|
292
|
+
* @returns The parsed number, or `null` when the text is not a finite decimal.
|
|
293
|
+
*/
|
|
294
|
+
function parseFiniteDecimal(text: string): number | null {
|
|
295
|
+
// Non-ambiguous: \d+(?:\.\d*)? avoids the \d+...\d* overlap that lets a long
|
|
296
|
+
// run of digits backtrack polynomially (ReDoS) on a near-match.
|
|
297
|
+
const pattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
298
|
+
if (!pattern.test(text)) {
|
|
299
|
+
return null;
|
|
300
|
+
}
|
|
301
|
+
const value = Number(text);
|
|
302
|
+
return Number.isFinite(value) ? value : null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// MARK: - Parser internals
|
|
306
|
+
|
|
307
|
+
interface FrontmatterField {
|
|
308
|
+
readonly key: string;
|
|
309
|
+
readonly value: string;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
interface ExtractedFrontmatter {
|
|
313
|
+
readonly fields: FrontmatterField[];
|
|
314
|
+
/** 1-based line number of the first body line, after the closing `---`. */
|
|
315
|
+
readonly bodyStartLine: number;
|
|
316
|
+
/** The remaining body lines after the closing fence. */
|
|
317
|
+
readonly body: string[];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
interface PendingPlane {
|
|
321
|
+
readonly lineNumber: number;
|
|
322
|
+
readonly attributes: Record<string, string>;
|
|
323
|
+
readonly bodyLines: string[];
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function extractFrontmatter(lines: readonly string[]): ExtractedFrontmatter {
|
|
327
|
+
let index = 0;
|
|
328
|
+
while (index < lines.length && trimWhitespace(lines[index] ?? "") === "") {
|
|
329
|
+
index += 1;
|
|
330
|
+
}
|
|
331
|
+
if (index >= lines.length || trimWhitespace(lines[index] ?? "") !== "---") {
|
|
332
|
+
throw new ParseError(
|
|
333
|
+
"missingFrontmatter",
|
|
334
|
+
"3md documents must begin with a '---' frontmatter block.",
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
index += 1;
|
|
339
|
+
const fields: FrontmatterField[] = [];
|
|
340
|
+
|
|
341
|
+
while (index < lines.length) {
|
|
342
|
+
const trimmed = trimWhitespace(lines[index] ?? "");
|
|
343
|
+
if (trimmed === "---") {
|
|
344
|
+
const bodyStart = index + 1;
|
|
345
|
+
const body = bodyStart < lines.length ? lines.slice(bodyStart) : [];
|
|
346
|
+
return { fields, bodyStartLine: index + 2, body };
|
|
347
|
+
}
|
|
348
|
+
if (trimmed !== "" && !trimmed.startsWith("#")) {
|
|
349
|
+
const separator = trimmed.indexOf(":");
|
|
350
|
+
if (separator < 0) {
|
|
351
|
+
throw new ParseError(
|
|
352
|
+
"invalidFrontmatter",
|
|
353
|
+
`Invalid frontmatter: expected 'key: value', found '${trimmed}'`,
|
|
354
|
+
undefined,
|
|
355
|
+
`expected 'key: value', found '${trimmed}'`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
const key = trimWhitespace(trimmed.slice(0, separator));
|
|
359
|
+
const raw = trimWhitespace(trimmed.slice(separator + 1));
|
|
360
|
+
fields.push({ key, value: unquote(raw) });
|
|
361
|
+
}
|
|
362
|
+
index += 1;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
throw new ParseError(
|
|
366
|
+
"invalidFrontmatter",
|
|
367
|
+
"Invalid frontmatter: frontmatter block was not closed with '---'",
|
|
368
|
+
undefined,
|
|
369
|
+
"frontmatter block was not closed with '---'",
|
|
370
|
+
);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
interface InterpretedFrontmatter {
|
|
374
|
+
readonly version: string;
|
|
375
|
+
readonly axis: Axis;
|
|
376
|
+
readonly title: string | null;
|
|
377
|
+
readonly metadata: Record<string, string>;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function interpretFrontmatter(fields: readonly FrontmatterField[]): InterpretedFrontmatter {
|
|
381
|
+
let version: string | null = null;
|
|
382
|
+
let axis: Axis = "layer";
|
|
383
|
+
let title: string | null = null;
|
|
384
|
+
// Null-prototype map: frontmatter keys come from an untrusted document, so a
|
|
385
|
+
// key like `__proto__` or `constructor` must land as plain data, never on the
|
|
386
|
+
// object's prototype. All keys are still preserved (parity with Swift/Rust).
|
|
387
|
+
const metadata: Record<string, string> = Object.create(null);
|
|
388
|
+
|
|
389
|
+
for (const field of fields) {
|
|
390
|
+
switch (field.key.toLowerCase()) {
|
|
391
|
+
case "3md":
|
|
392
|
+
version = field.value;
|
|
393
|
+
break;
|
|
394
|
+
case "axis":
|
|
395
|
+
axis = trimWhitespace(field.value).toLowerCase();
|
|
396
|
+
break;
|
|
397
|
+
case "title":
|
|
398
|
+
title = field.value;
|
|
399
|
+
break;
|
|
400
|
+
default:
|
|
401
|
+
metadata[field.key] = field.value;
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
if (version === null || version.length === 0) {
|
|
407
|
+
throw new ParseError(
|
|
408
|
+
"missingVersion",
|
|
409
|
+
"Frontmatter is missing the required '3md' version key.",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return { version, axis, title, metadata };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function parseDirective(trimmed: string, line: number): Record<string, string> {
|
|
416
|
+
const remainder = trimWhitespace(trimmed.slice("@plane".length));
|
|
417
|
+
const result: Record<string, string> = Object.create(null); // untrusted keys, no prototype
|
|
418
|
+
|
|
419
|
+
for (const token of tokenize(remainder, line)) {
|
|
420
|
+
const separator = token.indexOf("=");
|
|
421
|
+
if (separator < 0) {
|
|
422
|
+
throw new ParseError(
|
|
423
|
+
"invalidPlaneDirective",
|
|
424
|
+
`Invalid @plane directive on line ${line}: expected key=value, found '${token}'`,
|
|
425
|
+
line,
|
|
426
|
+
`expected key=value, found '${token}'`,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
const key = trimWhitespace(token.slice(0, separator)).toLowerCase();
|
|
430
|
+
const value = unquote(trimWhitespace(token.slice(separator + 1)));
|
|
431
|
+
if (key.length === 0) {
|
|
432
|
+
throw new ParseError(
|
|
433
|
+
"invalidPlaneDirective",
|
|
434
|
+
`Invalid @plane directive on line ${line}: empty attribute key in '${token}'`,
|
|
435
|
+
line,
|
|
436
|
+
`empty attribute key in '${token}'`,
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
result[key] = value;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return result;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
function optionalDouble(
|
|
446
|
+
value: string | undefined,
|
|
447
|
+
key: string,
|
|
448
|
+
line: number,
|
|
449
|
+
): number | null {
|
|
450
|
+
if (value === undefined) {
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const parsed = parseFiniteDecimal(value);
|
|
454
|
+
if (parsed === null) {
|
|
455
|
+
throw new ParseError(
|
|
456
|
+
"invalidPlaneDirective",
|
|
457
|
+
`Invalid @plane directive on line ${line}: ${key} must be a finite decimal number, found '${value}'`,
|
|
458
|
+
line,
|
|
459
|
+
`${key} must be a finite decimal number, found '${value}'`,
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
return parsed;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function makePlane(pending: PendingPlane): Plane {
|
|
466
|
+
const attributes = pending.attributes;
|
|
467
|
+
const line = pending.lineNumber;
|
|
468
|
+
|
|
469
|
+
const zRaw = attributes["z"];
|
|
470
|
+
if (zRaw === undefined) {
|
|
471
|
+
throw new ParseError(
|
|
472
|
+
"missingPlanePosition",
|
|
473
|
+
`The @plane directive on line ${line} is missing a 'z' position.`,
|
|
474
|
+
line,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
const z = parseFiniteDecimal(zRaw);
|
|
478
|
+
if (z === null) {
|
|
479
|
+
throw new ParseError(
|
|
480
|
+
"invalidPlaneDirective",
|
|
481
|
+
`Invalid @plane directive on line ${line}: z must be a finite decimal number, found '${zRaw}'`,
|
|
482
|
+
line,
|
|
483
|
+
`z must be a finite decimal number, found '${zRaw}'`,
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const x = optionalDouble(attributes["x"], "x", line);
|
|
488
|
+
const y = optionalDouble(attributes["y"], "y", line);
|
|
489
|
+
const labelRaw = attributes["label"];
|
|
490
|
+
const label = labelRaw === undefined ? null : labelRaw;
|
|
491
|
+
|
|
492
|
+
const extras: Record<string, string> = Object.create(null); // untrusted keys, no prototype
|
|
493
|
+
for (const attributeKey of Object.keys(attributes)) {
|
|
494
|
+
if (!RESERVED_PLANE_KEYS.has(attributeKey)) {
|
|
495
|
+
const attributeValue = attributes[attributeKey];
|
|
496
|
+
if (attributeValue !== undefined) {
|
|
497
|
+
extras[attributeKey] = attributeValue;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const body = collapse(pending.bodyLines) ?? "";
|
|
503
|
+
return { z, label, x, y, attributes: extras, body };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
interface ParsedBody {
|
|
507
|
+
readonly preamble: string | null;
|
|
508
|
+
readonly planes: Plane[];
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function parseBody(lines: readonly string[], bodyStartLine: number): ParsedBody {
|
|
512
|
+
const seenZ = new Set<number>();
|
|
513
|
+
const planes: Plane[] = [];
|
|
514
|
+
let pending: PendingPlane | null = null;
|
|
515
|
+
const preambleLines: string[] = [];
|
|
516
|
+
let fence: string | null = null;
|
|
517
|
+
|
|
518
|
+
const flushPending = (): void => {
|
|
519
|
+
if (pending === null) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const plane = makePlane(pending);
|
|
523
|
+
if (seenZ.has(plane.z)) {
|
|
524
|
+
throw new ParseError(
|
|
525
|
+
"duplicatePlane",
|
|
526
|
+
`Two planes share the same z position: ${plane.z}`,
|
|
527
|
+
undefined,
|
|
528
|
+
String(plane.z),
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
seenZ.add(plane.z);
|
|
532
|
+
planes.push(plane);
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
lines.forEach((raw, offset) => {
|
|
536
|
+
const lineNumber = bodyStartLine + offset;
|
|
537
|
+
const trimmed = trimWhitespace(raw);
|
|
538
|
+
|
|
539
|
+
// A directive must begin at column 0 and sit outside a fenced code block,
|
|
540
|
+
// so a `@plane` line inside ``` or ~~~ (or indented as a code block) is
|
|
541
|
+
// body text, not a new plane.
|
|
542
|
+
let isDirective = false;
|
|
543
|
+
if (fence !== null) {
|
|
544
|
+
if (trimmed.startsWith(fence.repeat(3))) {
|
|
545
|
+
fence = null;
|
|
546
|
+
}
|
|
547
|
+
} else {
|
|
548
|
+
const opened = fenceCharacter(trimmed);
|
|
549
|
+
if (opened !== null) {
|
|
550
|
+
fence = opened;
|
|
551
|
+
} else if (raw[0] !== " " && raw[0] !== "\t" && firstToken(raw) === "@plane") {
|
|
552
|
+
isDirective = true;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!isDirective) {
|
|
557
|
+
if (pending !== null) {
|
|
558
|
+
pending.bodyLines.push(raw);
|
|
559
|
+
} else {
|
|
560
|
+
preambleLines.push(raw);
|
|
561
|
+
}
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
flushPending();
|
|
566
|
+
const attributes = parseDirective(trimmed, lineNumber);
|
|
567
|
+
pending = { lineNumber, attributes, bodyLines: [] };
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
flushPending();
|
|
571
|
+
|
|
572
|
+
const preamble = collapse(preambleLines);
|
|
573
|
+
|
|
574
|
+
if (planes.length === 0) {
|
|
575
|
+
if (preamble === null) {
|
|
576
|
+
return { preamble: null, planes: [] };
|
|
577
|
+
}
|
|
578
|
+
return {
|
|
579
|
+
preamble: null,
|
|
580
|
+
planes: [{ z: 0, label: null, x: null, y: null, attributes: Object.create(null), body: preamble }],
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
return { preamble, planes };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
// MARK: - Public API
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Parses 3md source text into a {@link Document}.
|
|
590
|
+
*
|
|
591
|
+
* Frontmatter is required and must declare a `3md` version key (the format's
|
|
592
|
+
* magic marker). Planes are introduced by `@plane` directives carrying
|
|
593
|
+
* `key=value` attributes; the text between directives is plain Markdown. A
|
|
594
|
+
* document with no directives parses as a single plane at `z = 0`.
|
|
595
|
+
*
|
|
596
|
+
* @param source The full contents of a `.3md` file.
|
|
597
|
+
* @returns The parsed document.
|
|
598
|
+
* @throws {ParseError} When the source is malformed. The error's `code`
|
|
599
|
+
* property names the canonical case (`missingFrontmatter`,
|
|
600
|
+
* `invalidFrontmatter`, `missingVersion`, `missingPlanePosition`,
|
|
601
|
+
* `invalidPlaneDirective`, or `duplicatePlane`).
|
|
602
|
+
*/
|
|
603
|
+
export function parse(source: string): Document {
|
|
604
|
+
let normalized = source.replace(/\r\n/g, "\n");
|
|
605
|
+
if (normalized.charCodeAt(0) === 0xfeff) {
|
|
606
|
+
normalized = normalized.slice(1);
|
|
607
|
+
}
|
|
608
|
+
const lines = normalized.split("\n");
|
|
609
|
+
|
|
610
|
+
const frontmatter = extractFrontmatter(lines);
|
|
611
|
+
const interpreted = interpretFrontmatter(frontmatter.fields);
|
|
612
|
+
const { preamble, planes } = parseBody(frontmatter.body, frontmatter.bodyStartLine);
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
version: interpreted.version,
|
|
616
|
+
axis: interpreted.axis,
|
|
617
|
+
title: interpreted.title,
|
|
618
|
+
metadata: interpreted.metadata,
|
|
619
|
+
preamble,
|
|
620
|
+
planes,
|
|
621
|
+
};
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Extracts every cross-plane link from a {@link Document}'s plane bodies.
|
|
626
|
+
*
|
|
627
|
+
* A cross-plane link matches the regular expression
|
|
628
|
+
* `\[\[z=([^\]|]+)(?:\|([^\]]*))?\]\]`. The captured target is validated as a
|
|
629
|
+
* finite decimal with the same grammar as the `z` attribute (see
|
|
630
|
+
* `parseFiniteDecimal`); if it is not a finite decimal, the sequence is not a
|
|
631
|
+
* link and is ignored. The optional second group is the link text: `null` when
|
|
632
|
+
* absent, the captured value (which may be `""`) when present.
|
|
633
|
+
*
|
|
634
|
+
* Links are returned in document order: planes in source order, then links left
|
|
635
|
+
* to right within each body. `targetExists` reports whether any plane in the
|
|
636
|
+
* document has a `z` strictly equal to the link's target.
|
|
637
|
+
*
|
|
638
|
+
* @param document The document to scan.
|
|
639
|
+
* @returns The extracted cross-plane links, in document order.
|
|
640
|
+
*/
|
|
641
|
+
export function links(document: Document): CrossPlaneLink[] {
|
|
642
|
+
const result: CrossPlaneLink[] = [];
|
|
643
|
+
const targets = new Set<number>(document.planes.map((plane) => plane.z));
|
|
644
|
+
|
|
645
|
+
for (const plane of document.planes) {
|
|
646
|
+
// z is a short finite decimal; the link text is short. Bounded quantifiers
|
|
647
|
+
// make this provably linear (no polynomial backtracking - CodeQL clean).
|
|
648
|
+
// parseFiniteDecimal still validates the captured z value.
|
|
649
|
+
const pattern = /\[\[z=([-+0-9eE.]{1,40})(?:\|([^\]\n]{0,400}))?\]\]/g;
|
|
650
|
+
let match: RegExpExecArray | null = pattern.exec(plane.body);
|
|
651
|
+
while (match !== null) {
|
|
652
|
+
const targetZ = parseFiniteDecimal(match[1] ?? "");
|
|
653
|
+
if (targetZ !== null) {
|
|
654
|
+
const rawText = match[2];
|
|
655
|
+
result.push({
|
|
656
|
+
sourceZ: plane.z,
|
|
657
|
+
targetZ,
|
|
658
|
+
text: rawText === undefined ? null : rawText,
|
|
659
|
+
targetExists: targets.has(targetZ),
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
match = pattern.exec(plane.body);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
return result;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/**
|
|
670
|
+
* Returns only cross-plane links whose target plane does not exist.
|
|
671
|
+
*
|
|
672
|
+
* @param document The document to scan.
|
|
673
|
+
* @returns Dangling cross-plane links in document order.
|
|
674
|
+
*/
|
|
675
|
+
export function danglingLinks(document: Document): CrossPlaneLink[] {
|
|
676
|
+
return links(document).filter((link) => !link.targetExists);
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Returns a compact directed graph of cross-plane references.
|
|
681
|
+
*
|
|
682
|
+
* Multiple links with the same source and target collapse into one edge with an
|
|
683
|
+
* incremented count. The returned edges preserve the first time each
|
|
684
|
+
* source-target pair appears in the document.
|
|
685
|
+
*
|
|
686
|
+
* @param document The document to scan.
|
|
687
|
+
* @returns Cross-plane graph edges in first encounter order.
|
|
688
|
+
*/
|
|
689
|
+
export function linkGraph(document: Document): CrossPlaneLinkEdge[] {
|
|
690
|
+
const edges: CrossPlaneLinkEdge[] = [];
|
|
691
|
+
const indexByKey = new Map<string, number>();
|
|
692
|
+
|
|
693
|
+
for (const link of links(document)) {
|
|
694
|
+
const key = `${link.sourceZ}\u0000${link.targetZ}`;
|
|
695
|
+
const index = indexByKey.get(key);
|
|
696
|
+
if (index === undefined) {
|
|
697
|
+
indexByKey.set(key, edges.length);
|
|
698
|
+
edges.push({
|
|
699
|
+
sourceZ: link.sourceZ,
|
|
700
|
+
targetZ: link.targetZ,
|
|
701
|
+
targetExists: link.targetExists,
|
|
702
|
+
count: 1,
|
|
703
|
+
});
|
|
704
|
+
} else {
|
|
705
|
+
const existing = edges[index];
|
|
706
|
+
if (existing !== undefined) {
|
|
707
|
+
edges[index] = { ...existing, count: existing.count + 1 };
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
return edges;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* Formats a number as an integer string when the value is whole and in range,
|
|
717
|
+
* falling back to the default decimal representation otherwise. Mirrors the
|
|
718
|
+
* Swift serializer's `format(_:)` helper.
|
|
719
|
+
*/
|
|
720
|
+
function formatNumber(value: number): string {
|
|
721
|
+
if (Number.isInteger(value) && Math.abs(value) < 1e15) {
|
|
722
|
+
return String(value);
|
|
723
|
+
}
|
|
724
|
+
return String(value);
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* Wraps a value in double quotes when it contains whitespace, is empty, or when
|
|
729
|
+
* `forceQuote` is true. Embedded double quotes are escaped. Mirrors the Swift
|
|
730
|
+
* serializer's `quoteIfNeeded(_:forceQuote:)` helper.
|
|
731
|
+
*/
|
|
732
|
+
function quoteIfNeeded(value: string, forceQuote = false): string {
|
|
733
|
+
const needsQuote =
|
|
734
|
+
forceQuote ||
|
|
735
|
+
value.includes(" ") ||
|
|
736
|
+
value.includes("\t") ||
|
|
737
|
+
value.includes('"') ||
|
|
738
|
+
value.includes("\\") ||
|
|
739
|
+
value.length === 0;
|
|
740
|
+
if (!needsQuote) {
|
|
741
|
+
return value;
|
|
742
|
+
}
|
|
743
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
744
|
+
return `"${escaped}"`;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function frontmatterLines(document: Document): string[] {
|
|
748
|
+
const lines = ["---", `3md: ${document.version}`, `axis: ${document.axis}`];
|
|
749
|
+
|
|
750
|
+
if (document.title !== null) {
|
|
751
|
+
lines.push(`title: ${quoteIfNeeded(document.title)}`);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const key of Object.keys(document.metadata).sort()) {
|
|
755
|
+
lines.push(`${key}: ${quoteIfNeeded(document.metadata[key] ?? "")}`);
|
|
756
|
+
}
|
|
757
|
+
lines.push("---");
|
|
758
|
+
|
|
759
|
+
return lines;
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
function directiveLine(plane: Plane): string {
|
|
763
|
+
const parts = ["@plane", `z=${formatNumber(plane.z)}`];
|
|
764
|
+
|
|
765
|
+
if (plane.label !== null) {
|
|
766
|
+
parts.push(`label=${quoteIfNeeded(plane.label, true)}`);
|
|
767
|
+
}
|
|
768
|
+
if (plane.x !== null) {
|
|
769
|
+
parts.push(`x=${formatNumber(plane.x)}`);
|
|
770
|
+
}
|
|
771
|
+
if (plane.y !== null) {
|
|
772
|
+
parts.push(`y=${formatNumber(plane.y)}`);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
for (const key of Object.keys(plane.attributes).sort()) {
|
|
776
|
+
parts.push(`${key}=${quoteIfNeeded(plane.attributes[key] ?? "", true)}`);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return parts.join(" ");
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
/**
|
|
783
|
+
* Renders a {@link Document} back into 3md source text.
|
|
784
|
+
*
|
|
785
|
+
* The output round-trips through {@link parse}: parsing serialized text yields
|
|
786
|
+
* an equivalent document for content that does not rely on quote escaping.
|
|
787
|
+
* Mirrors the Swift `Serializer`.
|
|
788
|
+
*
|
|
789
|
+
* @param document The document to render.
|
|
790
|
+
* @returns The serialized `.3md` contents.
|
|
791
|
+
*/
|
|
792
|
+
export function serialize(document: Document): string {
|
|
793
|
+
const lines = frontmatterLines(document);
|
|
794
|
+
|
|
795
|
+
if (document.preamble !== null) {
|
|
796
|
+
lines.push("");
|
|
797
|
+
lines.push(document.preamble);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
for (const plane of document.planes) {
|
|
801
|
+
lines.push("");
|
|
802
|
+
lines.push(directiveLine(plane));
|
|
803
|
+
if (plane.body.length > 0) {
|
|
804
|
+
lines.push(plane.body);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
return lines.join("\n") + "\n";
|
|
809
|
+
}
|