@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/README.md
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# @corvidlabs/threemd
|
|
2
|
+
|
|
3
|
+
Parser and serializer for the **3md** format: Markdown with a Z axis. A `.3md`
|
|
4
|
+
file is ordinary Markdown extended along one free axis, so you can stack content
|
|
5
|
+
into **planes** and tell the reader what the depth means (time for a planner,
|
|
6
|
+
frames for an animation, layers for annotations, space for a scene).
|
|
7
|
+
|
|
8
|
+
This package is a faithful TypeScript port of the Swift reference parser
|
|
9
|
+
(`ThreeMD`). The two implementations are pinned to identical behavior by a shared
|
|
10
|
+
conformance suite, so parsing the same document in either language yields the
|
|
11
|
+
same result.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bun add @corvidlabs/threemd
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { danglingLinks, linkGraph, parse, serialize } from "@corvidlabs/threemd";
|
|
23
|
+
|
|
24
|
+
const source = `---
|
|
25
|
+
3md: 0.1
|
|
26
|
+
axis: time
|
|
27
|
+
title: My Week
|
|
28
|
+
---
|
|
29
|
+
@plane z=0 label="Monday"
|
|
30
|
+
# Monday
|
|
31
|
+
- [ ] Standup
|
|
32
|
+
|
|
33
|
+
@plane z=1 label="Tuesday"
|
|
34
|
+
# Tuesday
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
const document = parse(source);
|
|
38
|
+
console.log(document.axis); // "time"
|
|
39
|
+
console.log(document.planes.length); // 2
|
|
40
|
+
console.log(danglingLinks(document)); // unresolved [[z=N]] references
|
|
41
|
+
console.log(linkGraph(document)); // compact source -> target edge list
|
|
42
|
+
|
|
43
|
+
// Round trips back to text:
|
|
44
|
+
const text = serialize(document);
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`parse` throws a `ParseError` on malformed input; its `code` property names the
|
|
48
|
+
canonical case (`missingFrontmatter`, `invalidFrontmatter`, `missingVersion`,
|
|
49
|
+
`missingPlanePosition`, `invalidPlaneDirective`, or `duplicatePlane`).
|
|
50
|
+
|
|
51
|
+
## License
|
|
52
|
+
|
|
53
|
+
MIT
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The meaning assigned to a 3md document's third (Z) axis.
|
|
3
|
+
*
|
|
4
|
+
* The value is always trimmed and lowercased, matching the Swift `Axis` type.
|
|
5
|
+
* Any string is permitted; common values are `time`, `depth`, `layer`,
|
|
6
|
+
* `frame`, and `space`.
|
|
7
|
+
*/
|
|
8
|
+
export type Axis = string;
|
|
9
|
+
/**
|
|
10
|
+
* A single slice of a 3md document positioned along the Z axis.
|
|
11
|
+
*
|
|
12
|
+
* `z` is required and orders planes along the document's axis. `x` and `y` are
|
|
13
|
+
* optional in-plane offsets. Any directive attributes other than `z`, `x`, `y`,
|
|
14
|
+
* and `label` are preserved in `attributes`.
|
|
15
|
+
*/
|
|
16
|
+
export interface Plane {
|
|
17
|
+
/** Position along the document's Z axis. */
|
|
18
|
+
readonly z: number;
|
|
19
|
+
/** Optional human-readable label, or `null` when absent. */
|
|
20
|
+
readonly label: string | null;
|
|
21
|
+
/** Optional horizontal offset within the plane, or `null` when absent. */
|
|
22
|
+
readonly x: number | null;
|
|
23
|
+
/** Optional vertical offset within the plane, or `null` when absent. */
|
|
24
|
+
readonly y: number | null;
|
|
25
|
+
/** Extra directive attributes that are not `z`, `x`, `y`, or `label`. */
|
|
26
|
+
readonly attributes: Readonly<Record<string, string>>;
|
|
27
|
+
/** The Markdown content of this plane, with surrounding blank lines trimmed. */
|
|
28
|
+
readonly body: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* A reference from one plane's Markdown body to another plane by its `z`
|
|
32
|
+
* position, written `[[z=N]]` or `[[z=N|text]]`.
|
|
33
|
+
*
|
|
34
|
+
* Cross-plane links live verbatim inside plane bodies; {@link links} extracts
|
|
35
|
+
* them in document order. This mirrors the cross-language contract pinned by the
|
|
36
|
+
* `links-*.json` conformance vectors.
|
|
37
|
+
*/
|
|
38
|
+
export interface CrossPlaneLink {
|
|
39
|
+
/** The `z` position of the plane whose body contains this link. */
|
|
40
|
+
readonly sourceZ: number;
|
|
41
|
+
/** The `z` position the link points at, parsed as a finite decimal. */
|
|
42
|
+
readonly targetZ: number;
|
|
43
|
+
/** The optional link text: `null` when absent, `""` when present but empty. */
|
|
44
|
+
readonly text: string | null;
|
|
45
|
+
/** Whether a plane with `z === targetZ` exists in the document. */
|
|
46
|
+
readonly targetExists: boolean;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A summarized directed edge in a document's cross-plane link graph.
|
|
50
|
+
*
|
|
51
|
+
* Multiple `[[z=N]]` links from the same source plane to the same target plane
|
|
52
|
+
* collapse into one edge with an incremented `count`.
|
|
53
|
+
*/
|
|
54
|
+
export interface CrossPlaneLinkEdge {
|
|
55
|
+
/** The `z` position of the plane whose body contains the link. */
|
|
56
|
+
readonly sourceZ: number;
|
|
57
|
+
/** The target `z` position named by the link. */
|
|
58
|
+
readonly targetZ: number;
|
|
59
|
+
/** Whether a plane with `z === targetZ` exists in the document. */
|
|
60
|
+
readonly targetExists: boolean;
|
|
61
|
+
/** Number of links collapsed into this edge. */
|
|
62
|
+
readonly count: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* A parsed 3md document: Markdown extended along one free Z axis.
|
|
66
|
+
*/
|
|
67
|
+
export interface Document {
|
|
68
|
+
/** The declared 3md format version, for example `"0.1"`. */
|
|
69
|
+
readonly version: string;
|
|
70
|
+
/** What the Z axis represents in this document (trimmed, lowercased). */
|
|
71
|
+
readonly axis: Axis;
|
|
72
|
+
/** Optional document title from the frontmatter, or `null`. */
|
|
73
|
+
readonly title: string | null;
|
|
74
|
+
/** Any frontmatter keys other than `3md`, `axis`, and `title`. */
|
|
75
|
+
readonly metadata: Readonly<Record<string, string>>;
|
|
76
|
+
/** Markdown content after the frontmatter but before the first plane, or `null`. */
|
|
77
|
+
readonly preamble: string | null;
|
|
78
|
+
/** The document's planes, in source order. */
|
|
79
|
+
readonly planes: readonly Plane[];
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The set of error case names a conforming 3md parser must reject with.
|
|
83
|
+
*
|
|
84
|
+
* These mirror the Swift `ParseError` enum cases one to one.
|
|
85
|
+
*/
|
|
86
|
+
export type ParseErrorCode = "missingFrontmatter" | "invalidFrontmatter" | "missingVersion" | "invalidPlaneDirective" | "missingPlanePosition" | "duplicatePlane";
|
|
87
|
+
/**
|
|
88
|
+
* An error thrown while parsing a 3md document.
|
|
89
|
+
*
|
|
90
|
+
* The `code` property names the canonical Swift `ParseError` case, so callers
|
|
91
|
+
* and the conformance suite can match on it without parsing the message.
|
|
92
|
+
*/
|
|
93
|
+
export declare class ParseError extends Error {
|
|
94
|
+
/** The canonical Swift `ParseError` case name. */
|
|
95
|
+
readonly code: ParseErrorCode;
|
|
96
|
+
/** Optional 1-based line number for directive-related errors. */
|
|
97
|
+
readonly line: number | undefined;
|
|
98
|
+
/** Optional human-readable detail string. */
|
|
99
|
+
readonly detail: string | undefined;
|
|
100
|
+
constructor(code: ParseErrorCode, message: string, line?: number, detail?: string);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Parses 3md source text into a {@link Document}.
|
|
104
|
+
*
|
|
105
|
+
* Frontmatter is required and must declare a `3md` version key (the format's
|
|
106
|
+
* magic marker). Planes are introduced by `@plane` directives carrying
|
|
107
|
+
* `key=value` attributes; the text between directives is plain Markdown. A
|
|
108
|
+
* document with no directives parses as a single plane at `z = 0`.
|
|
109
|
+
*
|
|
110
|
+
* @param source The full contents of a `.3md` file.
|
|
111
|
+
* @returns The parsed document.
|
|
112
|
+
* @throws {ParseError} When the source is malformed. The error's `code`
|
|
113
|
+
* property names the canonical case (`missingFrontmatter`,
|
|
114
|
+
* `invalidFrontmatter`, `missingVersion`, `missingPlanePosition`,
|
|
115
|
+
* `invalidPlaneDirective`, or `duplicatePlane`).
|
|
116
|
+
*/
|
|
117
|
+
export declare function parse(source: string): Document;
|
|
118
|
+
/**
|
|
119
|
+
* Extracts every cross-plane link from a {@link Document}'s plane bodies.
|
|
120
|
+
*
|
|
121
|
+
* A cross-plane link matches the regular expression
|
|
122
|
+
* `\[\[z=([^\]|]+)(?:\|([^\]]*))?\]\]`. The captured target is validated as a
|
|
123
|
+
* finite decimal with the same grammar as the `z` attribute (see
|
|
124
|
+
* `parseFiniteDecimal`); if it is not a finite decimal, the sequence is not a
|
|
125
|
+
* link and is ignored. The optional second group is the link text: `null` when
|
|
126
|
+
* absent, the captured value (which may be `""`) when present.
|
|
127
|
+
*
|
|
128
|
+
* Links are returned in document order: planes in source order, then links left
|
|
129
|
+
* to right within each body. `targetExists` reports whether any plane in the
|
|
130
|
+
* document has a `z` strictly equal to the link's target.
|
|
131
|
+
*
|
|
132
|
+
* @param document The document to scan.
|
|
133
|
+
* @returns The extracted cross-plane links, in document order.
|
|
134
|
+
*/
|
|
135
|
+
export declare function links(document: Document): CrossPlaneLink[];
|
|
136
|
+
/**
|
|
137
|
+
* Returns only cross-plane links whose target plane does not exist.
|
|
138
|
+
*
|
|
139
|
+
* @param document The document to scan.
|
|
140
|
+
* @returns Dangling cross-plane links in document order.
|
|
141
|
+
*/
|
|
142
|
+
export declare function danglingLinks(document: Document): CrossPlaneLink[];
|
|
143
|
+
/**
|
|
144
|
+
* Returns a compact directed graph of cross-plane references.
|
|
145
|
+
*
|
|
146
|
+
* Multiple links with the same source and target collapse into one edge with an
|
|
147
|
+
* incremented count. The returned edges preserve the first time each
|
|
148
|
+
* source-target pair appears in the document.
|
|
149
|
+
*
|
|
150
|
+
* @param document The document to scan.
|
|
151
|
+
* @returns Cross-plane graph edges in first encounter order.
|
|
152
|
+
*/
|
|
153
|
+
export declare function linkGraph(document: Document): CrossPlaneLinkEdge[];
|
|
154
|
+
/**
|
|
155
|
+
* Renders a {@link Document} back into 3md source text.
|
|
156
|
+
*
|
|
157
|
+
* The output round-trips through {@link parse}: parsing serialized text yields
|
|
158
|
+
* an equivalent document for content that does not rely on quote escaping.
|
|
159
|
+
* Mirrors the Swift `Serializer`.
|
|
160
|
+
*
|
|
161
|
+
* @param document The document to render.
|
|
162
|
+
* @returns The serialized `.3md` contents.
|
|
163
|
+
*/
|
|
164
|
+
export declare function serialize(document: Document): string;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
class ParseError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
line;
|
|
5
|
+
detail;
|
|
6
|
+
constructor(code, message, line, detail) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ParseError";
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.line = line;
|
|
11
|
+
this.detail = detail;
|
|
12
|
+
Object.setPrototypeOf(this, ParseError.prototype);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
var RESERVED_PLANE_KEYS = new Set(["z", "x", "y", "label"]);
|
|
16
|
+
function trimWhitespace(value) {
|
|
17
|
+
return value.replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
|
|
18
|
+
}
|
|
19
|
+
function firstToken(line) {
|
|
20
|
+
for (const token of line.split(/[ \t]+/)) {
|
|
21
|
+
if (token.length > 0) {
|
|
22
|
+
return token;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return "";
|
|
26
|
+
}
|
|
27
|
+
function tokenize(input, line) {
|
|
28
|
+
const tokens = [];
|
|
29
|
+
let current = "";
|
|
30
|
+
let activeQuote = null;
|
|
31
|
+
let escaped = false;
|
|
32
|
+
for (const character of input) {
|
|
33
|
+
if (activeQuote !== null) {
|
|
34
|
+
current += character;
|
|
35
|
+
if (escaped) {
|
|
36
|
+
escaped = false;
|
|
37
|
+
} else if (character === "\\") {
|
|
38
|
+
escaped = true;
|
|
39
|
+
} else if (character === activeQuote) {
|
|
40
|
+
activeQuote = null;
|
|
41
|
+
}
|
|
42
|
+
} else if (character === '"' || character === "'") {
|
|
43
|
+
activeQuote = character;
|
|
44
|
+
current += character;
|
|
45
|
+
} else if (character === " " || character === "\t") {
|
|
46
|
+
if (current.length > 0) {
|
|
47
|
+
tokens.push(current);
|
|
48
|
+
current = "";
|
|
49
|
+
}
|
|
50
|
+
} else {
|
|
51
|
+
current += character;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
if (activeQuote !== null) {
|
|
55
|
+
throw new ParseError("invalidPlaneDirective", `Invalid @plane directive on line ${line}: unterminated quote in '${input}'`, line, `unterminated quote in '${input}'`);
|
|
56
|
+
}
|
|
57
|
+
if (current.length > 0) {
|
|
58
|
+
tokens.push(current);
|
|
59
|
+
}
|
|
60
|
+
return tokens;
|
|
61
|
+
}
|
|
62
|
+
function unquote(value) {
|
|
63
|
+
if (value.length < 2) {
|
|
64
|
+
return value;
|
|
65
|
+
}
|
|
66
|
+
const first = value[0];
|
|
67
|
+
const last = value[value.length - 1];
|
|
68
|
+
if (first === '"' && last === '"' || first === "'" && last === "'") {
|
|
69
|
+
return unescapeValue(value.slice(1, -1));
|
|
70
|
+
}
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
73
|
+
function unescapeValue(value) {
|
|
74
|
+
let result = "";
|
|
75
|
+
let escaping = false;
|
|
76
|
+
for (const character of value) {
|
|
77
|
+
if (escaping) {
|
|
78
|
+
result += character;
|
|
79
|
+
escaping = false;
|
|
80
|
+
} else if (character === "\\") {
|
|
81
|
+
escaping = true;
|
|
82
|
+
} else {
|
|
83
|
+
result += character;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (escaping) {
|
|
87
|
+
result += "\\";
|
|
88
|
+
}
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
function fenceCharacter(trimmed) {
|
|
92
|
+
if (trimmed.startsWith("```")) {
|
|
93
|
+
return "`";
|
|
94
|
+
}
|
|
95
|
+
if (trimmed.startsWith("~~~")) {
|
|
96
|
+
return "~";
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
function collapse(lines) {
|
|
101
|
+
let start = 0;
|
|
102
|
+
let end = lines.length;
|
|
103
|
+
while (start < end && trimWhitespace(lines[start] ?? "") === "") {
|
|
104
|
+
start += 1;
|
|
105
|
+
}
|
|
106
|
+
while (end > start && trimWhitespace(lines[end - 1] ?? "") === "") {
|
|
107
|
+
end -= 1;
|
|
108
|
+
}
|
|
109
|
+
if (start >= end) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
return lines.slice(start, end).join(`
|
|
113
|
+
`);
|
|
114
|
+
}
|
|
115
|
+
function parseFiniteDecimal(text) {
|
|
116
|
+
const pattern = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
117
|
+
if (!pattern.test(text)) {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
const value = Number(text);
|
|
121
|
+
return Number.isFinite(value) ? value : null;
|
|
122
|
+
}
|
|
123
|
+
function extractFrontmatter(lines) {
|
|
124
|
+
let index = 0;
|
|
125
|
+
while (index < lines.length && trimWhitespace(lines[index] ?? "") === "") {
|
|
126
|
+
index += 1;
|
|
127
|
+
}
|
|
128
|
+
if (index >= lines.length || trimWhitespace(lines[index] ?? "") !== "---") {
|
|
129
|
+
throw new ParseError("missingFrontmatter", "3md documents must begin with a '---' frontmatter block.");
|
|
130
|
+
}
|
|
131
|
+
index += 1;
|
|
132
|
+
const fields = [];
|
|
133
|
+
while (index < lines.length) {
|
|
134
|
+
const trimmed = trimWhitespace(lines[index] ?? "");
|
|
135
|
+
if (trimmed === "---") {
|
|
136
|
+
const bodyStart = index + 1;
|
|
137
|
+
const body = bodyStart < lines.length ? lines.slice(bodyStart) : [];
|
|
138
|
+
return { fields, bodyStartLine: index + 2, body };
|
|
139
|
+
}
|
|
140
|
+
if (trimmed !== "" && !trimmed.startsWith("#")) {
|
|
141
|
+
const separator = trimmed.indexOf(":");
|
|
142
|
+
if (separator < 0) {
|
|
143
|
+
throw new ParseError("invalidFrontmatter", `Invalid frontmatter: expected 'key: value', found '${trimmed}'`, undefined, `expected 'key: value', found '${trimmed}'`);
|
|
144
|
+
}
|
|
145
|
+
const key = trimWhitespace(trimmed.slice(0, separator));
|
|
146
|
+
const raw = trimWhitespace(trimmed.slice(separator + 1));
|
|
147
|
+
fields.push({ key, value: unquote(raw) });
|
|
148
|
+
}
|
|
149
|
+
index += 1;
|
|
150
|
+
}
|
|
151
|
+
throw new ParseError("invalidFrontmatter", "Invalid frontmatter: frontmatter block was not closed with '---'", undefined, "frontmatter block was not closed with '---'");
|
|
152
|
+
}
|
|
153
|
+
function interpretFrontmatter(fields) {
|
|
154
|
+
let version = null;
|
|
155
|
+
let axis = "layer";
|
|
156
|
+
let title = null;
|
|
157
|
+
const metadata = Object.create(null);
|
|
158
|
+
for (const field of fields) {
|
|
159
|
+
switch (field.key.toLowerCase()) {
|
|
160
|
+
case "3md":
|
|
161
|
+
version = field.value;
|
|
162
|
+
break;
|
|
163
|
+
case "axis":
|
|
164
|
+
axis = trimWhitespace(field.value).toLowerCase();
|
|
165
|
+
break;
|
|
166
|
+
case "title":
|
|
167
|
+
title = field.value;
|
|
168
|
+
break;
|
|
169
|
+
default:
|
|
170
|
+
metadata[field.key] = field.value;
|
|
171
|
+
break;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (version === null || version.length === 0) {
|
|
175
|
+
throw new ParseError("missingVersion", "Frontmatter is missing the required '3md' version key.");
|
|
176
|
+
}
|
|
177
|
+
return { version, axis, title, metadata };
|
|
178
|
+
}
|
|
179
|
+
function parseDirective(trimmed, line) {
|
|
180
|
+
const remainder = trimWhitespace(trimmed.slice("@plane".length));
|
|
181
|
+
const result = Object.create(null);
|
|
182
|
+
for (const token of tokenize(remainder, line)) {
|
|
183
|
+
const separator = token.indexOf("=");
|
|
184
|
+
if (separator < 0) {
|
|
185
|
+
throw new ParseError("invalidPlaneDirective", `Invalid @plane directive on line ${line}: expected key=value, found '${token}'`, line, `expected key=value, found '${token}'`);
|
|
186
|
+
}
|
|
187
|
+
const key = trimWhitespace(token.slice(0, separator)).toLowerCase();
|
|
188
|
+
const value = unquote(trimWhitespace(token.slice(separator + 1)));
|
|
189
|
+
if (key.length === 0) {
|
|
190
|
+
throw new ParseError("invalidPlaneDirective", `Invalid @plane directive on line ${line}: empty attribute key in '${token}'`, line, `empty attribute key in '${token}'`);
|
|
191
|
+
}
|
|
192
|
+
result[key] = value;
|
|
193
|
+
}
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
function optionalDouble(value, key, line) {
|
|
197
|
+
if (value === undefined) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
const parsed = parseFiniteDecimal(value);
|
|
201
|
+
if (parsed === null) {
|
|
202
|
+
throw new ParseError("invalidPlaneDirective", `Invalid @plane directive on line ${line}: ${key} must be a finite decimal number, found '${value}'`, line, `${key} must be a finite decimal number, found '${value}'`);
|
|
203
|
+
}
|
|
204
|
+
return parsed;
|
|
205
|
+
}
|
|
206
|
+
function makePlane(pending) {
|
|
207
|
+
const attributes = pending.attributes;
|
|
208
|
+
const line = pending.lineNumber;
|
|
209
|
+
const zRaw = attributes["z"];
|
|
210
|
+
if (zRaw === undefined) {
|
|
211
|
+
throw new ParseError("missingPlanePosition", `The @plane directive on line ${line} is missing a 'z' position.`, line);
|
|
212
|
+
}
|
|
213
|
+
const z = parseFiniteDecimal(zRaw);
|
|
214
|
+
if (z === null) {
|
|
215
|
+
throw new ParseError("invalidPlaneDirective", `Invalid @plane directive on line ${line}: z must be a finite decimal number, found '${zRaw}'`, line, `z must be a finite decimal number, found '${zRaw}'`);
|
|
216
|
+
}
|
|
217
|
+
const x = optionalDouble(attributes["x"], "x", line);
|
|
218
|
+
const y = optionalDouble(attributes["y"], "y", line);
|
|
219
|
+
const labelRaw = attributes["label"];
|
|
220
|
+
const label = labelRaw === undefined ? null : labelRaw;
|
|
221
|
+
const extras = Object.create(null);
|
|
222
|
+
for (const attributeKey of Object.keys(attributes)) {
|
|
223
|
+
if (!RESERVED_PLANE_KEYS.has(attributeKey)) {
|
|
224
|
+
const attributeValue = attributes[attributeKey];
|
|
225
|
+
if (attributeValue !== undefined) {
|
|
226
|
+
extras[attributeKey] = attributeValue;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const body = collapse(pending.bodyLines) ?? "";
|
|
231
|
+
return { z, label, x, y, attributes: extras, body };
|
|
232
|
+
}
|
|
233
|
+
function parseBody(lines, bodyStartLine) {
|
|
234
|
+
const seenZ = new Set;
|
|
235
|
+
const planes = [];
|
|
236
|
+
let pending = null;
|
|
237
|
+
const preambleLines = [];
|
|
238
|
+
let fence = null;
|
|
239
|
+
const flushPending = () => {
|
|
240
|
+
if (pending === null) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
const plane = makePlane(pending);
|
|
244
|
+
if (seenZ.has(plane.z)) {
|
|
245
|
+
throw new ParseError("duplicatePlane", `Two planes share the same z position: ${plane.z}`, undefined, String(plane.z));
|
|
246
|
+
}
|
|
247
|
+
seenZ.add(plane.z);
|
|
248
|
+
planes.push(plane);
|
|
249
|
+
};
|
|
250
|
+
lines.forEach((raw, offset) => {
|
|
251
|
+
const lineNumber = bodyStartLine + offset;
|
|
252
|
+
const trimmed = trimWhitespace(raw);
|
|
253
|
+
let isDirective = false;
|
|
254
|
+
if (fence !== null) {
|
|
255
|
+
if (trimmed.startsWith(fence.repeat(3))) {
|
|
256
|
+
fence = null;
|
|
257
|
+
}
|
|
258
|
+
} else {
|
|
259
|
+
const opened = fenceCharacter(trimmed);
|
|
260
|
+
if (opened !== null) {
|
|
261
|
+
fence = opened;
|
|
262
|
+
} else if (raw[0] !== " " && raw[0] !== "\t" && firstToken(raw) === "@plane") {
|
|
263
|
+
isDirective = true;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
if (!isDirective) {
|
|
267
|
+
if (pending !== null) {
|
|
268
|
+
pending.bodyLines.push(raw);
|
|
269
|
+
} else {
|
|
270
|
+
preambleLines.push(raw);
|
|
271
|
+
}
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
flushPending();
|
|
275
|
+
const attributes = parseDirective(trimmed, lineNumber);
|
|
276
|
+
pending = { lineNumber, attributes, bodyLines: [] };
|
|
277
|
+
});
|
|
278
|
+
flushPending();
|
|
279
|
+
const preamble = collapse(preambleLines);
|
|
280
|
+
if (planes.length === 0) {
|
|
281
|
+
if (preamble === null) {
|
|
282
|
+
return { preamble: null, planes: [] };
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
preamble: null,
|
|
286
|
+
planes: [{ z: 0, label: null, x: null, y: null, attributes: Object.create(null), body: preamble }]
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
return { preamble, planes };
|
|
290
|
+
}
|
|
291
|
+
function parse(source) {
|
|
292
|
+
let normalized = source.replace(/\r\n/g, `
|
|
293
|
+
`);
|
|
294
|
+
if (normalized.charCodeAt(0) === 65279) {
|
|
295
|
+
normalized = normalized.slice(1);
|
|
296
|
+
}
|
|
297
|
+
const lines = normalized.split(`
|
|
298
|
+
`);
|
|
299
|
+
const frontmatter = extractFrontmatter(lines);
|
|
300
|
+
const interpreted = interpretFrontmatter(frontmatter.fields);
|
|
301
|
+
const { preamble, planes } = parseBody(frontmatter.body, frontmatter.bodyStartLine);
|
|
302
|
+
return {
|
|
303
|
+
version: interpreted.version,
|
|
304
|
+
axis: interpreted.axis,
|
|
305
|
+
title: interpreted.title,
|
|
306
|
+
metadata: interpreted.metadata,
|
|
307
|
+
preamble,
|
|
308
|
+
planes
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function links(document) {
|
|
312
|
+
const result = [];
|
|
313
|
+
const targets = new Set(document.planes.map((plane) => plane.z));
|
|
314
|
+
for (const plane of document.planes) {
|
|
315
|
+
const pattern = /\[\[z=([-+0-9eE.]{1,40})(?:\|([^\]\n]{0,400}))?\]\]/g;
|
|
316
|
+
let match = pattern.exec(plane.body);
|
|
317
|
+
while (match !== null) {
|
|
318
|
+
const targetZ = parseFiniteDecimal(match[1] ?? "");
|
|
319
|
+
if (targetZ !== null) {
|
|
320
|
+
const rawText = match[2];
|
|
321
|
+
result.push({
|
|
322
|
+
sourceZ: plane.z,
|
|
323
|
+
targetZ,
|
|
324
|
+
text: rawText === undefined ? null : rawText,
|
|
325
|
+
targetExists: targets.has(targetZ)
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
match = pattern.exec(plane.body);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return result;
|
|
332
|
+
}
|
|
333
|
+
function danglingLinks(document) {
|
|
334
|
+
return links(document).filter((link) => !link.targetExists);
|
|
335
|
+
}
|
|
336
|
+
function linkGraph(document) {
|
|
337
|
+
const edges = [];
|
|
338
|
+
const indexByKey = new Map;
|
|
339
|
+
for (const link of links(document)) {
|
|
340
|
+
const key = `${link.sourceZ}\x00${link.targetZ}`;
|
|
341
|
+
const index = indexByKey.get(key);
|
|
342
|
+
if (index === undefined) {
|
|
343
|
+
indexByKey.set(key, edges.length);
|
|
344
|
+
edges.push({
|
|
345
|
+
sourceZ: link.sourceZ,
|
|
346
|
+
targetZ: link.targetZ,
|
|
347
|
+
targetExists: link.targetExists,
|
|
348
|
+
count: 1
|
|
349
|
+
});
|
|
350
|
+
} else {
|
|
351
|
+
const existing = edges[index];
|
|
352
|
+
if (existing !== undefined) {
|
|
353
|
+
edges[index] = { ...existing, count: existing.count + 1 };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return edges;
|
|
358
|
+
}
|
|
359
|
+
function formatNumber(value) {
|
|
360
|
+
if (Number.isInteger(value) && Math.abs(value) < 1000000000000000) {
|
|
361
|
+
return String(value);
|
|
362
|
+
}
|
|
363
|
+
return String(value);
|
|
364
|
+
}
|
|
365
|
+
function quoteIfNeeded(value, forceQuote = false) {
|
|
366
|
+
const needsQuote = forceQuote || value.includes(" ") || value.includes("\t") || value.includes('"') || value.includes("\\") || value.length === 0;
|
|
367
|
+
if (!needsQuote) {
|
|
368
|
+
return value;
|
|
369
|
+
}
|
|
370
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, "\\\"");
|
|
371
|
+
return `"${escaped}"`;
|
|
372
|
+
}
|
|
373
|
+
function frontmatterLines(document) {
|
|
374
|
+
const lines = ["---", `3md: ${document.version}`, `axis: ${document.axis}`];
|
|
375
|
+
if (document.title !== null) {
|
|
376
|
+
lines.push(`title: ${quoteIfNeeded(document.title)}`);
|
|
377
|
+
}
|
|
378
|
+
for (const key of Object.keys(document.metadata).sort()) {
|
|
379
|
+
lines.push(`${key}: ${quoteIfNeeded(document.metadata[key] ?? "")}`);
|
|
380
|
+
}
|
|
381
|
+
lines.push("---");
|
|
382
|
+
return lines;
|
|
383
|
+
}
|
|
384
|
+
function directiveLine(plane) {
|
|
385
|
+
const parts = ["@plane", `z=${formatNumber(plane.z)}`];
|
|
386
|
+
if (plane.label !== null) {
|
|
387
|
+
parts.push(`label=${quoteIfNeeded(plane.label, true)}`);
|
|
388
|
+
}
|
|
389
|
+
if (plane.x !== null) {
|
|
390
|
+
parts.push(`x=${formatNumber(plane.x)}`);
|
|
391
|
+
}
|
|
392
|
+
if (plane.y !== null) {
|
|
393
|
+
parts.push(`y=${formatNumber(plane.y)}`);
|
|
394
|
+
}
|
|
395
|
+
for (const key of Object.keys(plane.attributes).sort()) {
|
|
396
|
+
parts.push(`${key}=${quoteIfNeeded(plane.attributes[key] ?? "", true)}`);
|
|
397
|
+
}
|
|
398
|
+
return parts.join(" ");
|
|
399
|
+
}
|
|
400
|
+
function serialize(document) {
|
|
401
|
+
const lines = frontmatterLines(document);
|
|
402
|
+
if (document.preamble !== null) {
|
|
403
|
+
lines.push("");
|
|
404
|
+
lines.push(document.preamble);
|
|
405
|
+
}
|
|
406
|
+
for (const plane of document.planes) {
|
|
407
|
+
lines.push("");
|
|
408
|
+
lines.push(directiveLine(plane));
|
|
409
|
+
if (plane.body.length > 0) {
|
|
410
|
+
lines.push(plane.body);
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return lines.join(`
|
|
414
|
+
`) + `
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
export {
|
|
418
|
+
serialize,
|
|
419
|
+
parse,
|
|
420
|
+
links,
|
|
421
|
+
linkGraph,
|
|
422
|
+
danglingLinks,
|
|
423
|
+
ParseError
|
|
424
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@corvidlabs/threemd",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Parser and serializer for the 3md format: Markdown with a Z axis.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": ["3md", "markdown", "parser", "serializer", "z-axis", "file-format", "corvidlabs"],
|
|
8
|
+
"repository": { "type": "git", "url": "git+https://github.com/CorvidLabs/3md.git", "directory": "js" },
|
|
9
|
+
"homepage": "https://github.com/CorvidLabs/3md#readme",
|
|
10
|
+
"bugs": { "url": "https://github.com/CorvidLabs/3md/issues" },
|
|
11
|
+
"publishConfig": { "access": "public" },
|
|
12
|
+
"main": "./dist/index.js",
|
|
13
|
+
"module": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"import": "./dist/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"src"
|
|
24
|
+
],
|
|
25
|
+
"scripts": {
|
|
26
|
+
"test": "bun test",
|
|
27
|
+
"build": "bun build ./src/index.ts --outdir ./dist --target node --format esm && tsc -p tsconfig.build.json",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"prepublishOnly": "bun run build"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@types/bun": "latest",
|
|
33
|
+
"typescript": "^5.6.0"
|
|
34
|
+
}
|
|
35
|
+
}
|