@entropicwarrior/sdoc 0.1.3
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 +21 -0
- package/README.md +110 -0
- package/index.js +2 -0
- package/package.json +165 -0
- package/src/notion-renderer.js +451 -0
- package/src/sdoc.js +2223 -0
package/src/sdoc.js
ADDED
|
@@ -0,0 +1,2223 @@
|
|
|
1
|
+
const COMMAND_HEADING = "#";
|
|
2
|
+
const COMMAND_SCOPE_OPEN = "{";
|
|
3
|
+
const COMMAND_SCOPE_CLOSE = "}";
|
|
4
|
+
const COMMAND_LIST_BULLET = "{[.]";
|
|
5
|
+
const COMMAND_LIST_NUMBER = "{[#]";
|
|
6
|
+
const COMMAND_TABLE = "{[table]";
|
|
7
|
+
const COMMAND_CODE_FENCE = "```";
|
|
8
|
+
|
|
9
|
+
const ESCAPABLE = new Set(["\\", "{", "}", "@", "[", "]", "(", ")", "*", "`", "#", "!", "~", "<", ">"]);
|
|
10
|
+
|
|
11
|
+
function isTableCommand(text) {
|
|
12
|
+
return /^\{\[table(?:\s+[^\]]*?)?\]$/.test(text);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function parseTableOptions(text) {
|
|
16
|
+
const match = text.match(/^\{\[table(?:\s+(.*))?\]$/);
|
|
17
|
+
if (!match) return {};
|
|
18
|
+
const flagStr = (match[1] || "").trim();
|
|
19
|
+
const flags = flagStr ? flagStr.split(/\s+/) : [];
|
|
20
|
+
const options = { borderless: false, headerless: false };
|
|
21
|
+
for (const flag of flags) {
|
|
22
|
+
if (flag === "borderless") options.borderless = true;
|
|
23
|
+
else if (flag === "headerless") options.headerless = true;
|
|
24
|
+
}
|
|
25
|
+
return options;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
class LineCursor {
|
|
29
|
+
constructor(lines) {
|
|
30
|
+
this.lines = lines;
|
|
31
|
+
this.index = 0;
|
|
32
|
+
this.errors = [];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
eof() {
|
|
36
|
+
return this.index >= this.lines.length;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
current() {
|
|
40
|
+
return this.lines[this.index] ?? "";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
next() {
|
|
44
|
+
this.index += 1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
error(message) {
|
|
48
|
+
this.errors.push({ message, line: this.index + 1 });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function parseSdoc(text) {
|
|
53
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
54
|
+
const cursor = new LineCursor(normalized.split("\n"));
|
|
55
|
+
|
|
56
|
+
// Check for implicit root: first non-blank line is a heading, next non-blank is NOT a block opener
|
|
57
|
+
const implicitRoot = detectImplicitRoot(cursor);
|
|
58
|
+
if (implicitRoot) {
|
|
59
|
+
const scopeStartLine = cursor.index + 1;
|
|
60
|
+
const parsedHeading = parseHeading(cursor.current());
|
|
61
|
+
cursor.next();
|
|
62
|
+
const children = parseBlock(cursor, "normal");
|
|
63
|
+
const rootNode = {
|
|
64
|
+
type: "scope",
|
|
65
|
+
title: parsedHeading.title,
|
|
66
|
+
id: parsedHeading.id,
|
|
67
|
+
children,
|
|
68
|
+
hasHeading: true,
|
|
69
|
+
lineStart: scopeStartLine,
|
|
70
|
+
lineEnd: cursor.index
|
|
71
|
+
};
|
|
72
|
+
return { nodes: [rootNode], errors: cursor.errors };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const nodes = parseBlock(cursor, "normal");
|
|
76
|
+
return { nodes, errors: cursor.errors };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectImplicitRoot(cursor) {
|
|
80
|
+
const saved = cursor.index;
|
|
81
|
+
// Find first non-blank line
|
|
82
|
+
while (!cursor.eof()) {
|
|
83
|
+
const trimmed = cursor.current().trim();
|
|
84
|
+
if (trimmed !== "") break;
|
|
85
|
+
cursor.next();
|
|
86
|
+
}
|
|
87
|
+
if (cursor.eof()) { cursor.index = saved; return false; }
|
|
88
|
+
|
|
89
|
+
const firstLine = cursor.current();
|
|
90
|
+
const trimmedLeft = firstLine.replace(/^\s+/, "");
|
|
91
|
+
if (!isHeadingLine(trimmedLeft)) { cursor.index = saved; return false; }
|
|
92
|
+
|
|
93
|
+
// Check if heading has trailing opener (K&R style) — if so, it's explicit
|
|
94
|
+
const stripped = stripHeadingToken(trimmedLeft);
|
|
95
|
+
const trailing = extractTrailingOpener(stripped);
|
|
96
|
+
if (trailing) { cursor.index = saved; return false; }
|
|
97
|
+
|
|
98
|
+
// Peek at the next non-blank line after the heading
|
|
99
|
+
const headingIndex = cursor.index;
|
|
100
|
+
cursor.index = headingIndex + 1;
|
|
101
|
+
while (!cursor.eof()) {
|
|
102
|
+
const trimmed = cursor.current().trim();
|
|
103
|
+
if (trimmed !== "") break;
|
|
104
|
+
cursor.next();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
let isImplicit = false;
|
|
108
|
+
if (cursor.eof()) {
|
|
109
|
+
// Heading followed by nothing — implicit root with no content
|
|
110
|
+
isImplicit = true;
|
|
111
|
+
} else {
|
|
112
|
+
const nextTrimmed = cursor.current().replace(/^\s+/, "").trim();
|
|
113
|
+
// If next non-blank is a block opener, it's explicit
|
|
114
|
+
if (nextTrimmed === COMMAND_SCOPE_OPEN ||
|
|
115
|
+
nextTrimmed === COMMAND_LIST_BULLET ||
|
|
116
|
+
nextTrimmed === COMMAND_LIST_NUMBER ||
|
|
117
|
+
isTableCommand(nextTrimmed)) {
|
|
118
|
+
isImplicit = false;
|
|
119
|
+
} else if (tryParseInlineBlock(nextTrimmed) !== null) {
|
|
120
|
+
isImplicit = false;
|
|
121
|
+
} else {
|
|
122
|
+
isImplicit = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Restore cursor to first non-blank (the heading line)
|
|
127
|
+
cursor.index = headingIndex;
|
|
128
|
+
if (!isImplicit) {
|
|
129
|
+
cursor.index = saved;
|
|
130
|
+
}
|
|
131
|
+
return isImplicit;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const BARE_DIRECTIVES = new Set(["meta", "about"]);
|
|
135
|
+
|
|
136
|
+
function parseBareDirective(trimmed) {
|
|
137
|
+
// Match @meta, @about, @meta {, @about {
|
|
138
|
+
if (!trimmed.startsWith("@")) return null;
|
|
139
|
+
const withoutAt = trimmed.slice(1);
|
|
140
|
+
// Check for "@directive {" (K&R style)
|
|
141
|
+
const spaceIdx = withoutAt.indexOf(" ");
|
|
142
|
+
if (spaceIdx === -1) {
|
|
143
|
+
// Bare "@directive" with no trailing brace — only valid if next line is "{"
|
|
144
|
+
return BARE_DIRECTIVES.has(withoutAt) ? { id: withoutAt, hasOpenBrace: false } : null;
|
|
145
|
+
}
|
|
146
|
+
const name = withoutAt.slice(0, spaceIdx);
|
|
147
|
+
const rest = withoutAt.slice(spaceIdx).trim();
|
|
148
|
+
if (!BARE_DIRECTIVES.has(name)) return null;
|
|
149
|
+
if (rest === COMMAND_SCOPE_OPEN) return { id: name, hasOpenBrace: true };
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function parseBareScope(cursor, directive) {
|
|
154
|
+
const scopeStartLine = cursor.index + 1;
|
|
155
|
+
cursor.next();
|
|
156
|
+
|
|
157
|
+
if (directive.hasOpenBrace) {
|
|
158
|
+
// Brace was on the same line — parse contents until closing }
|
|
159
|
+
const children = parseBlock(cursor, "normal");
|
|
160
|
+
return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Brace should be on the next non-blank line
|
|
164
|
+
const saved = cursor.index;
|
|
165
|
+
while (!cursor.eof() && cursor.current().trim() === "") {
|
|
166
|
+
cursor.next();
|
|
167
|
+
}
|
|
168
|
+
if (!cursor.eof() && cursor.current().trim() === COMMAND_SCOPE_OPEN) {
|
|
169
|
+
cursor.next();
|
|
170
|
+
const children = parseBlock(cursor, "normal");
|
|
171
|
+
return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// No opening brace found — treat as braceless scope (content until next heading, }, or EOF)
|
|
175
|
+
cursor.index = saved;
|
|
176
|
+
const children = parseBracelessBlock(cursor);
|
|
177
|
+
return { type: "scope", title: "", id: directive.id, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseBlock(cursor, kind) {
|
|
181
|
+
const nodes = [];
|
|
182
|
+
let paragraphLines = [];
|
|
183
|
+
let paragraphStartLine = 0;
|
|
184
|
+
|
|
185
|
+
const flushParagraph = () => {
|
|
186
|
+
if (!paragraphLines.length) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const text = paragraphLines.join(" ").trim();
|
|
190
|
+
if (text) {
|
|
191
|
+
nodes.push({ type: "paragraph", text, lineStart: paragraphStartLine, lineEnd: cursor.index });
|
|
192
|
+
}
|
|
193
|
+
paragraphLines = [];
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
while (!cursor.eof()) {
|
|
197
|
+
const line = cursor.current();
|
|
198
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
199
|
+
const trimmed = trimmedLeft.trim();
|
|
200
|
+
|
|
201
|
+
if (trimmed === "") {
|
|
202
|
+
flushParagraph();
|
|
203
|
+
cursor.next();
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) {
|
|
208
|
+
flushParagraph();
|
|
209
|
+
cursor.next();
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (trimmed === ",") {
|
|
214
|
+
flushParagraph();
|
|
215
|
+
cursor.next();
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (isHorizontalRule(trimmed)) {
|
|
220
|
+
flushParagraph();
|
|
221
|
+
const hrLine = cursor.index + 1;
|
|
222
|
+
nodes.push({ type: "hr", lineStart: hrLine, lineEnd: hrLine });
|
|
223
|
+
cursor.next();
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (isBlockquoteLine(trimmedLeft)) {
|
|
228
|
+
flushParagraph();
|
|
229
|
+
nodes.push(parseBlockquote(cursor));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (isFenceStart(trimmedLeft)) {
|
|
234
|
+
flushParagraph();
|
|
235
|
+
nodes.push(parseCodeBlock(cursor));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const implicitListInfo = getListItemInfo(trimmedLeft);
|
|
240
|
+
if (implicitListInfo && kind === "normal") {
|
|
241
|
+
flushParagraph();
|
|
242
|
+
nodes.push(parseImplicitListBlock(cursor, implicitListInfo.type));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const bareDirective = parseBareDirective(trimmed);
|
|
247
|
+
if (bareDirective) {
|
|
248
|
+
flushParagraph();
|
|
249
|
+
nodes.push(parseBareScope(cursor, bareDirective));
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (isHeadingLine(trimmedLeft)) {
|
|
254
|
+
flushParagraph();
|
|
255
|
+
nodes.push(parseScope(cursor));
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
|
|
260
|
+
flushParagraph();
|
|
261
|
+
nodes.push(parseListBlock(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number"));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (isTableCommand(trimmed)) {
|
|
266
|
+
flushParagraph();
|
|
267
|
+
nodes.push(parseTableBlock(cursor));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (trimmed === COMMAND_SCOPE_OPEN) {
|
|
272
|
+
flushParagraph();
|
|
273
|
+
const scopeStartLine = cursor.index + 1;
|
|
274
|
+
cursor.next();
|
|
275
|
+
const children = parseBlock(cursor, "normal");
|
|
276
|
+
nodes.push({ type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index });
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (kind === "list") {
|
|
281
|
+
flushParagraph();
|
|
282
|
+
cursor.error("Only scoped list items are allowed inside list blocks.");
|
|
283
|
+
cursor.next();
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (!paragraphLines.length) {
|
|
288
|
+
paragraphStartLine = cursor.index + 1;
|
|
289
|
+
}
|
|
290
|
+
paragraphLines.push(trimmedLeft.trim());
|
|
291
|
+
cursor.next();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
flushParagraph();
|
|
295
|
+
return nodes;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function extractTrailingOpener(text) {
|
|
299
|
+
const trimmed = text.trimEnd();
|
|
300
|
+
// Don't match if the line also ends with } (inline block like "{ content }")
|
|
301
|
+
if (trimmed.endsWith(COMMAND_SCOPE_CLOSE)) {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
// Check for table command with optional flags: {[table ...]}
|
|
305
|
+
const tableMatch = trimmed.match(/\{\[table(?:\s+[^\]]*?)?\]$/);
|
|
306
|
+
if (tableMatch) {
|
|
307
|
+
const pos = tableMatch.index;
|
|
308
|
+
if (!(pos > 0 && trimmed[pos - 1] === "\\")) {
|
|
309
|
+
return { text: trimmed.slice(0, pos).trimEnd(), opener: tableMatch[0] };
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
// Check other openers
|
|
313
|
+
const openers = [COMMAND_LIST_NUMBER, COMMAND_LIST_BULLET, COMMAND_SCOPE_OPEN];
|
|
314
|
+
for (const opener of openers) {
|
|
315
|
+
if (trimmed.endsWith(opener)) {
|
|
316
|
+
const pos = trimmed.length - opener.length;
|
|
317
|
+
// Don't match escaped braces
|
|
318
|
+
if (pos > 0 && trimmed[pos - 1] === "\\") {
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
return { text: trimmed.slice(0, pos).trimEnd(), opener };
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return null;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function parseScope(cursor) {
|
|
328
|
+
const scopeStartLine = cursor.index + 1;
|
|
329
|
+
const headingLine = cursor.current();
|
|
330
|
+
cursor.next();
|
|
331
|
+
|
|
332
|
+
const trimmedLeft = headingLine.replace(/^\s+/, "");
|
|
333
|
+
const stripped = stripHeadingToken(trimmedLeft);
|
|
334
|
+
const trailing = extractTrailingOpener(stripped);
|
|
335
|
+
|
|
336
|
+
if (trailing) {
|
|
337
|
+
const parsedHeading = parseHeadingText(trailing.text);
|
|
338
|
+
let children;
|
|
339
|
+
if (trailing.opener === COMMAND_LIST_BULLET || trailing.opener === COMMAND_LIST_NUMBER) {
|
|
340
|
+
const listBody = parseListBody(cursor, trailing.opener === COMMAND_LIST_BULLET ? "bullet" : "number");
|
|
341
|
+
children = [listBody];
|
|
342
|
+
} else if (isTableCommand(trailing.opener)) {
|
|
343
|
+
const tableOpts = parseTableOptions(trailing.opener);
|
|
344
|
+
children = [parseTableBody(cursor, scopeStartLine, tableOpts)];
|
|
345
|
+
} else {
|
|
346
|
+
children = parseBlock(cursor, "normal");
|
|
347
|
+
}
|
|
348
|
+
return {
|
|
349
|
+
type: "scope",
|
|
350
|
+
title: parsedHeading.title,
|
|
351
|
+
id: parsedHeading.id,
|
|
352
|
+
children,
|
|
353
|
+
hasHeading: true,
|
|
354
|
+
lineStart: scopeStartLine,
|
|
355
|
+
lineEnd: cursor.index
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const parsedHeading = parseHeading(headingLine);
|
|
360
|
+
const blockResult = parseScopeBlock(cursor);
|
|
361
|
+
|
|
362
|
+
if (blockResult.blockType === "braceless") {
|
|
363
|
+
const children = parseBracelessBlock(cursor);
|
|
364
|
+
return {
|
|
365
|
+
type: "scope",
|
|
366
|
+
title: parsedHeading.title,
|
|
367
|
+
id: parsedHeading.id,
|
|
368
|
+
children,
|
|
369
|
+
hasHeading: true,
|
|
370
|
+
lineStart: scopeStartLine,
|
|
371
|
+
lineEnd: cursor.index
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
if (blockResult.blockType === "list") {
|
|
376
|
+
return {
|
|
377
|
+
type: "scope",
|
|
378
|
+
title: parsedHeading.title,
|
|
379
|
+
id: parsedHeading.id,
|
|
380
|
+
children: [blockResult.children],
|
|
381
|
+
hasHeading: true,
|
|
382
|
+
lineStart: scopeStartLine,
|
|
383
|
+
lineEnd: cursor.index
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
type: "scope",
|
|
389
|
+
title: parsedHeading.title,
|
|
390
|
+
id: parsedHeading.id,
|
|
391
|
+
children: blockResult.children,
|
|
392
|
+
hasHeading: true,
|
|
393
|
+
lineStart: scopeStartLine,
|
|
394
|
+
lineEnd: cursor.index
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function tryParseInlineBlock(trimmed) {
|
|
399
|
+
// Check if line matches pattern { ... }
|
|
400
|
+
if (!trimmed.startsWith(COMMAND_SCOPE_OPEN) || !trimmed.endsWith(COMMAND_SCOPE_CLOSE)) {
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Extract content between { and }
|
|
405
|
+
const content = trimmed.slice(1, -1).trim();
|
|
406
|
+
|
|
407
|
+
// Check for nested braces - if there are any unescaped { or }, this isn't a simple inline block
|
|
408
|
+
let depth = 0;
|
|
409
|
+
let i = 0;
|
|
410
|
+
while (i < content.length) {
|
|
411
|
+
if (content[i] === "\\" && i + 1 < content.length && ESCAPABLE.has(content[i + 1])) {
|
|
412
|
+
i += 2;
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (content[i] === "{" || content[i] === "}") {
|
|
416
|
+
depth++;
|
|
417
|
+
}
|
|
418
|
+
i++;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// If we have nested braces, this isn't a simple inline block
|
|
422
|
+
if (depth > 0) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Return the content as text
|
|
427
|
+
return content;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function parseBracelessBlock(cursor) {
|
|
431
|
+
const nodes = [];
|
|
432
|
+
let paragraphLines = [];
|
|
433
|
+
let paragraphStartLine = 0;
|
|
434
|
+
|
|
435
|
+
const flushParagraph = () => {
|
|
436
|
+
if (!paragraphLines.length) return;
|
|
437
|
+
const text = paragraphLines.join(" ").trim();
|
|
438
|
+
if (text) {
|
|
439
|
+
nodes.push({ type: "paragraph", text, lineStart: paragraphStartLine, lineEnd: cursor.index });
|
|
440
|
+
}
|
|
441
|
+
paragraphLines = [];
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
while (!cursor.eof()) {
|
|
445
|
+
const line = cursor.current();
|
|
446
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
447
|
+
const trimmed = trimmedLeft.trim();
|
|
448
|
+
|
|
449
|
+
if (trimmed === "") {
|
|
450
|
+
flushParagraph();
|
|
451
|
+
cursor.next();
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// Stop on } — parent owns it, don't advance
|
|
456
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) {
|
|
457
|
+
flushParagraph();
|
|
458
|
+
break;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Stop on # heading — becomes sibling, don't advance
|
|
462
|
+
if (isHeadingLine(trimmedLeft)) {
|
|
463
|
+
flushParagraph();
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Stop on bare @meta / @about — becomes sibling, don't advance
|
|
468
|
+
if (parseBareDirective(trimmed)) {
|
|
469
|
+
flushParagraph();
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (trimmed === ",") {
|
|
474
|
+
flushParagraph();
|
|
475
|
+
cursor.next();
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (isHorizontalRule(trimmed)) {
|
|
480
|
+
flushParagraph();
|
|
481
|
+
const hrLine = cursor.index + 1;
|
|
482
|
+
nodes.push({ type: "hr", lineStart: hrLine, lineEnd: hrLine });
|
|
483
|
+
cursor.next();
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (isBlockquoteLine(trimmedLeft)) {
|
|
488
|
+
flushParagraph();
|
|
489
|
+
nodes.push(parseBlockquote(cursor));
|
|
490
|
+
continue;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (isFenceStart(trimmedLeft)) {
|
|
494
|
+
flushParagraph();
|
|
495
|
+
nodes.push(parseCodeBlock(cursor));
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const implicitListInfo = getListItemInfo(trimmedLeft);
|
|
500
|
+
if (implicitListInfo) {
|
|
501
|
+
flushParagraph();
|
|
502
|
+
nodes.push(parseImplicitListBlock(cursor, implicitListInfo.type));
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// Headingless scopes { ... }
|
|
507
|
+
if (trimmed === COMMAND_SCOPE_OPEN) {
|
|
508
|
+
flushParagraph();
|
|
509
|
+
const scopeStartLine = cursor.index + 1;
|
|
510
|
+
cursor.next();
|
|
511
|
+
const children = parseBlock(cursor, "normal");
|
|
512
|
+
nodes.push({ type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: scopeStartLine, lineEnd: cursor.index });
|
|
513
|
+
continue;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
|
|
517
|
+
flushParagraph();
|
|
518
|
+
nodes.push(parseListBlock(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number"));
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (isTableCommand(trimmed)) {
|
|
523
|
+
flushParagraph();
|
|
524
|
+
nodes.push(parseTableBlock(cursor));
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (!paragraphLines.length) {
|
|
529
|
+
paragraphStartLine = cursor.index + 1;
|
|
530
|
+
}
|
|
531
|
+
paragraphLines.push(trimmedLeft.trim());
|
|
532
|
+
cursor.next();
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
flushParagraph();
|
|
536
|
+
return nodes;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function parseScopeBlock(cursor) {
|
|
540
|
+
while (!cursor.eof()) {
|
|
541
|
+
const line = cursor.current();
|
|
542
|
+
const trimmed = line.replace(/^\s+/, "").trim();
|
|
543
|
+
|
|
544
|
+
if (trimmed === "") {
|
|
545
|
+
cursor.next();
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Try to parse as inline block: { content }
|
|
550
|
+
const inlineContent = tryParseInlineBlock(trimmed);
|
|
551
|
+
if (inlineContent !== null) {
|
|
552
|
+
const inlineLine = cursor.index + 1;
|
|
553
|
+
cursor.next();
|
|
554
|
+
if (inlineContent === "") {
|
|
555
|
+
return { blockType: "normal", children: [] };
|
|
556
|
+
}
|
|
557
|
+
return { blockType: "normal", children: [{ type: "paragraph", text: inlineContent, lineStart: inlineLine, lineEnd: inlineLine }] };
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
if (trimmed === COMMAND_SCOPE_OPEN) {
|
|
561
|
+
cursor.next();
|
|
562
|
+
return { blockType: "normal", children: parseBlock(cursor, "normal") };
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
|
|
566
|
+
cursor.next();
|
|
567
|
+
return {
|
|
568
|
+
blockType: "list",
|
|
569
|
+
children: parseListBody(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number")
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (isTableCommand(trimmed)) {
|
|
574
|
+
return { blockType: "normal", children: [parseTableBlock(cursor)] };
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// No block opener found — braceless scope
|
|
578
|
+
return { blockType: "braceless" };
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// EOF after heading — braceless scope with no content
|
|
582
|
+
return { blockType: "braceless" };
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
function parseListBlock(cursor, listType) {
|
|
586
|
+
cursor.next();
|
|
587
|
+
return parseListBody(cursor, listType);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
function parseListBody(cursor, listType) {
|
|
591
|
+
const listStartLine = cursor.index + 1;
|
|
592
|
+
const items = [];
|
|
593
|
+
|
|
594
|
+
while (!cursor.eof()) {
|
|
595
|
+
const line = cursor.current();
|
|
596
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
597
|
+
const trimmed = trimmedLeft.trim();
|
|
598
|
+
|
|
599
|
+
if (trimmed === "") {
|
|
600
|
+
cursor.next();
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) {
|
|
605
|
+
cursor.next();
|
|
606
|
+
break;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
if (trimmed === ",") {
|
|
610
|
+
cursor.next();
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
if (isHeadingLine(trimmedLeft)) {
|
|
615
|
+
items.push(parseScope(cursor));
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const itemInfo = getListItemInfo(trimmedLeft);
|
|
620
|
+
if (itemInfo) {
|
|
621
|
+
items.push(parseListItemLine(cursor, itemInfo, true));
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (trimmed === COMMAND_SCOPE_OPEN) {
|
|
626
|
+
items.push(parseAnonymousListItem(cursor));
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
cursor.error("Orphaned text in list block (no preceding list item).");
|
|
631
|
+
cursor.next();
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
return { type: "list", listType, items, lineStart: listStartLine, lineEnd: cursor.index };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function parseAnonymousListItem(cursor) {
|
|
638
|
+
const itemStartLine = cursor.index + 1;
|
|
639
|
+
const line = cursor.current();
|
|
640
|
+
const trimmed = line.replace(/^\s+/, "").trim();
|
|
641
|
+
if (trimmed !== COMMAND_SCOPE_OPEN) {
|
|
642
|
+
cursor.error("Expected '{' to start an anonymous list item.");
|
|
643
|
+
cursor.next();
|
|
644
|
+
return { type: "scope", title: "", id: undefined, children: [], hasHeading: false, lineStart: itemStartLine, lineEnd: cursor.index };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
cursor.next();
|
|
648
|
+
const children = parseBlock(cursor, "normal");
|
|
649
|
+
return { type: "scope", title: "", id: undefined, children, hasHeading: false, lineStart: itemStartLine, lineEnd: cursor.index };
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function getListItemInfo(line) {
|
|
653
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
654
|
+
if (trimmedLeft.startsWith("- ")) {
|
|
655
|
+
return { type: "bullet", text: trimmedLeft.slice(1).trim() };
|
|
656
|
+
}
|
|
657
|
+
const numberedMatch = trimmedLeft.match(/^(\d+)[.)]\s+(.*)$/);
|
|
658
|
+
if (numberedMatch) {
|
|
659
|
+
return { type: "number", text: numberedMatch[2] };
|
|
660
|
+
}
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function isListContinuationLine(trimmedLeft) {
|
|
665
|
+
const trimmed = trimmedLeft.trim();
|
|
666
|
+
if (trimmed === "") return false;
|
|
667
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) return false;
|
|
668
|
+
if (trimmed === COMMAND_SCOPE_OPEN) return false;
|
|
669
|
+
if (trimmed === COMMAND_LIST_BULLET) return false;
|
|
670
|
+
if (trimmed === COMMAND_LIST_NUMBER) return false;
|
|
671
|
+
if (isTableCommand(trimmed)) return false;
|
|
672
|
+
if (trimmed === ",") return false;
|
|
673
|
+
if (isHeadingLine(trimmedLeft)) return false;
|
|
674
|
+
if (isBlockquoteLine(trimmedLeft)) return false;
|
|
675
|
+
if (isFenceStart(trimmedLeft)) return false;
|
|
676
|
+
if (isHorizontalRule(trimmed)) return false;
|
|
677
|
+
if (getListItemInfo(trimmedLeft)) return false;
|
|
678
|
+
if (tryParseInlineBlock(trimmed) !== null) return false;
|
|
679
|
+
return true;
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function parseListItemLine(cursor, info, allowContinuation = false) {
|
|
683
|
+
const itemStartLine = cursor.index + 1;
|
|
684
|
+
const raw = info.text;
|
|
685
|
+
const task = parseTaskPrefix(raw);
|
|
686
|
+
const textForOpener = task ? task.text : raw;
|
|
687
|
+
const trailing = extractTrailingOpener(textForOpener);
|
|
688
|
+
|
|
689
|
+
if (trailing) {
|
|
690
|
+
const parsed = parseHeadingText(trailing.text);
|
|
691
|
+
cursor.next();
|
|
692
|
+
|
|
693
|
+
let children;
|
|
694
|
+
if (trailing.opener === COMMAND_LIST_BULLET || trailing.opener === COMMAND_LIST_NUMBER) {
|
|
695
|
+
const listBody = parseListBody(cursor, trailing.opener === COMMAND_LIST_BULLET ? "bullet" : "number");
|
|
696
|
+
children = [listBody];
|
|
697
|
+
} else if (isTableCommand(trailing.opener)) {
|
|
698
|
+
const tableOpts = parseTableOptions(trailing.opener);
|
|
699
|
+
children = [parseTableBody(cursor, itemStartLine, tableOpts)];
|
|
700
|
+
} else {
|
|
701
|
+
children = parseBlock(cursor, "normal");
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return {
|
|
705
|
+
type: "scope",
|
|
706
|
+
title: parsed.title,
|
|
707
|
+
id: parsed.id,
|
|
708
|
+
children,
|
|
709
|
+
hasHeading: true,
|
|
710
|
+
shorthand: true,
|
|
711
|
+
task: task ? { checked: task.checked } : undefined,
|
|
712
|
+
lineStart: itemStartLine,
|
|
713
|
+
lineEnd: cursor.index
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
cursor.next();
|
|
718
|
+
|
|
719
|
+
// Collect continuation lines in explicit list blocks
|
|
720
|
+
let fullText = task ? task.text : raw;
|
|
721
|
+
if (allowContinuation) {
|
|
722
|
+
while (!cursor.eof()) {
|
|
723
|
+
const nextLine = cursor.current();
|
|
724
|
+
const nextTrimmedLeft = nextLine.replace(/^\s+/, "");
|
|
725
|
+
if (!isListContinuationLine(nextTrimmedLeft)) break;
|
|
726
|
+
fullText += " " + nextTrimmedLeft.trim();
|
|
727
|
+
cursor.next();
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const parsed = parseHeadingText(fullText);
|
|
732
|
+
|
|
733
|
+
const block = parseOptionalBlock(cursor);
|
|
734
|
+
if (!block) {
|
|
735
|
+
return {
|
|
736
|
+
type: "scope",
|
|
737
|
+
title: parsed.title,
|
|
738
|
+
id: parsed.id,
|
|
739
|
+
children: [],
|
|
740
|
+
hasHeading: true,
|
|
741
|
+
shorthand: true,
|
|
742
|
+
task: task ? { checked: task.checked } : undefined,
|
|
743
|
+
lineStart: itemStartLine,
|
|
744
|
+
lineEnd: cursor.index
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
if (block.blockType === "list") {
|
|
749
|
+
return {
|
|
750
|
+
type: "scope",
|
|
751
|
+
title: parsed.title,
|
|
752
|
+
id: parsed.id,
|
|
753
|
+
children: [block.children],
|
|
754
|
+
hasHeading: true,
|
|
755
|
+
shorthand: true,
|
|
756
|
+
task: task ? { checked: task.checked } : undefined,
|
|
757
|
+
lineStart: itemStartLine,
|
|
758
|
+
lineEnd: cursor.index
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
return {
|
|
763
|
+
type: "scope",
|
|
764
|
+
title: parsed.title,
|
|
765
|
+
id: parsed.id,
|
|
766
|
+
children: block.children,
|
|
767
|
+
hasHeading: true,
|
|
768
|
+
shorthand: true,
|
|
769
|
+
task: task ? { checked: task.checked } : undefined,
|
|
770
|
+
lineStart: itemStartLine,
|
|
771
|
+
lineEnd: cursor.index
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function parseImplicitListBlock(cursor, listType) {
|
|
776
|
+
const listStartLine = cursor.index + 1;
|
|
777
|
+
const items = [];
|
|
778
|
+
while (!cursor.eof()) {
|
|
779
|
+
const line = cursor.current();
|
|
780
|
+
const info = getListItemInfo(line);
|
|
781
|
+
if (!info) {
|
|
782
|
+
break;
|
|
783
|
+
}
|
|
784
|
+
if (info.type !== listType) {
|
|
785
|
+
break;
|
|
786
|
+
}
|
|
787
|
+
items.push(parseListItemLine(cursor, info));
|
|
788
|
+
}
|
|
789
|
+
return { type: "list", listType, items, lineStart: listStartLine, lineEnd: cursor.index };
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
function parseTableBlock(cursor) {
|
|
793
|
+
const tableStartLine = cursor.index + 1;
|
|
794
|
+
const options = parseTableOptions(cursor.current().trim());
|
|
795
|
+
cursor.next();
|
|
796
|
+
return parseTableBody(cursor, tableStartLine, options);
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function parseTableBody(cursor, tableStartLine, options) {
|
|
800
|
+
options = options || {};
|
|
801
|
+
const rows = [];
|
|
802
|
+
|
|
803
|
+
while (!cursor.eof()) {
|
|
804
|
+
const line = cursor.current();
|
|
805
|
+
const trimmed = line.replace(/^\s+/, "").trim();
|
|
806
|
+
|
|
807
|
+
if (trimmed === "") {
|
|
808
|
+
cursor.next();
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) {
|
|
813
|
+
cursor.next();
|
|
814
|
+
break;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
const cells = trimmed.split("|").map((cell) => cell.trim());
|
|
818
|
+
rows.push(cells);
|
|
819
|
+
cursor.next();
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (options.headerless) {
|
|
823
|
+
const tableNode = { type: "table", headers: [], rows, lineStart: tableStartLine, lineEnd: cursor.index };
|
|
824
|
+
if (options.borderless || options.headerless) tableNode.options = options;
|
|
825
|
+
return tableNode;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
const headers = rows.length > 0 ? rows[0] : [];
|
|
829
|
+
const body = rows.slice(1);
|
|
830
|
+
const tableNode = { type: "table", headers, rows: body, lineStart: tableStartLine, lineEnd: cursor.index };
|
|
831
|
+
if (options.borderless) tableNode.options = options;
|
|
832
|
+
return tableNode;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
function parseOptionalBlock(cursor) {
|
|
836
|
+
while (!cursor.eof()) {
|
|
837
|
+
const line = cursor.current();
|
|
838
|
+
const trimmed = line.replace(/^\s+/, "").trim();
|
|
839
|
+
|
|
840
|
+
if (trimmed === "") {
|
|
841
|
+
cursor.next();
|
|
842
|
+
continue;
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
// Try to parse as inline block: { content }
|
|
846
|
+
const inlineContent = tryParseInlineBlock(trimmed);
|
|
847
|
+
if (inlineContent !== null) {
|
|
848
|
+
const inlineLine = cursor.index + 1;
|
|
849
|
+
cursor.next();
|
|
850
|
+
if (inlineContent === "") {
|
|
851
|
+
return { blockType: "normal", children: [] };
|
|
852
|
+
}
|
|
853
|
+
return { blockType: "normal", children: [{ type: "paragraph", text: inlineContent, lineStart: inlineLine, lineEnd: inlineLine }] };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
if (trimmed === COMMAND_SCOPE_OPEN) {
|
|
857
|
+
cursor.next();
|
|
858
|
+
return { blockType: "normal", children: parseBlock(cursor, "normal") };
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (trimmed === COMMAND_LIST_BULLET || trimmed === COMMAND_LIST_NUMBER) {
|
|
862
|
+
cursor.next();
|
|
863
|
+
return {
|
|
864
|
+
blockType: "list",
|
|
865
|
+
children: parseListBody(cursor, trimmed === COMMAND_LIST_BULLET ? "bullet" : "number")
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
if (isTableCommand(trimmed)) {
|
|
870
|
+
return { blockType: "normal", children: [parseTableBlock(cursor)] };
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
return null;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function parseTaskPrefix(raw) {
|
|
880
|
+
const match = raw.match(/^\[( |x|X)\]\s*(.*)$/);
|
|
881
|
+
if (!match) {
|
|
882
|
+
return null;
|
|
883
|
+
}
|
|
884
|
+
return { checked: match[1].toLowerCase() === "x", text: match[2] };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function parseFenceMetadata(meta) {
|
|
888
|
+
if (!meta) return {};
|
|
889
|
+
const tokens = meta.split(/\s+/).filter(Boolean);
|
|
890
|
+
let lang, src, lines;
|
|
891
|
+
for (const token of tokens) {
|
|
892
|
+
if (token.startsWith("src:")) {
|
|
893
|
+
src = token.slice(4);
|
|
894
|
+
} else if (token.startsWith("lines:")) {
|
|
895
|
+
const range = token.slice(6);
|
|
896
|
+
const match = range.match(/^(\d+)-(\d+)$/);
|
|
897
|
+
if (match) {
|
|
898
|
+
lines = { start: parseInt(match[1], 10), end: parseInt(match[2], 10) };
|
|
899
|
+
}
|
|
900
|
+
} else if (!lang) {
|
|
901
|
+
lang = token;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
return { lang, src, lines };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function parseCodeBlock(cursor) {
|
|
908
|
+
const codeStartLine = cursor.index + 1;
|
|
909
|
+
const line = cursor.current();
|
|
910
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
911
|
+
const fenceMatch = trimmedLeft.match(/^(`{3,})/);
|
|
912
|
+
const fenceLen = fenceMatch ? fenceMatch[1].length : 3;
|
|
913
|
+
const metaStr = trimmedLeft.slice(fenceLen).trim();
|
|
914
|
+
const fenceMeta = parseFenceMetadata(metaStr);
|
|
915
|
+
const lang = fenceMeta.lang || undefined;
|
|
916
|
+
// Indentation of the opening fence — strip this much from content lines.
|
|
917
|
+
const fenceIndent = line.length - trimmedLeft.length;
|
|
918
|
+
cursor.next();
|
|
919
|
+
|
|
920
|
+
const contentLines = [];
|
|
921
|
+
|
|
922
|
+
while (!cursor.eof()) {
|
|
923
|
+
const nextLine = cursor.current();
|
|
924
|
+
const nextTrimmed = nextLine.replace(/^\s+/, "");
|
|
925
|
+
const closeMatch = nextTrimmed.match(/^(`{3,})\s*$/);
|
|
926
|
+
if (closeMatch && closeMatch[1].length >= fenceLen) {
|
|
927
|
+
cursor.next();
|
|
928
|
+
const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
|
|
929
|
+
if (fenceMeta.src) node.src = fenceMeta.src;
|
|
930
|
+
if (fenceMeta.lines) node.lines = fenceMeta.lines;
|
|
931
|
+
return node;
|
|
932
|
+
}
|
|
933
|
+
contentLines.push(nextLine);
|
|
934
|
+
cursor.next();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
cursor.error("Unterminated code fence.");
|
|
938
|
+
const node = { type: "code", lang, text: stripIndent(contentLines, fenceIndent), lineStart: codeStartLine, lineEnd: cursor.index };
|
|
939
|
+
if (fenceMeta.src) node.src = fenceMeta.src;
|
|
940
|
+
if (fenceMeta.lines) node.lines = fenceMeta.lines;
|
|
941
|
+
return node;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Strip up to `indent` leading whitespace characters from each line.
|
|
946
|
+
* Preserves any extra indentation beyond the baseline.
|
|
947
|
+
*/
|
|
948
|
+
function stripIndent(lines, indent) {
|
|
949
|
+
const re = new RegExp(`^\\s{0,${indent}}`);
|
|
950
|
+
return lines.map((l) => l.replace(re, "")).join("\n");
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function parseBlockquote(cursor) {
|
|
954
|
+
const bqStartLine = cursor.index + 1;
|
|
955
|
+
const paragraphs = [];
|
|
956
|
+
let paragraphLines = [];
|
|
957
|
+
|
|
958
|
+
const flushParagraph = () => {
|
|
959
|
+
if (!paragraphLines.length) {
|
|
960
|
+
return;
|
|
961
|
+
}
|
|
962
|
+
const text = paragraphLines.join(" ").trim();
|
|
963
|
+
if (text) {
|
|
964
|
+
paragraphs.push(text);
|
|
965
|
+
}
|
|
966
|
+
paragraphLines = [];
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
while (!cursor.eof()) {
|
|
970
|
+
const line = cursor.current();
|
|
971
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
972
|
+
if (!isBlockquoteLine(trimmedLeft)) {
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
let content = trimmedLeft.slice(1);
|
|
976
|
+
if (content.startsWith(" ")) {
|
|
977
|
+
content = content.slice(1);
|
|
978
|
+
}
|
|
979
|
+
if (content.trim() === "") {
|
|
980
|
+
flushParagraph();
|
|
981
|
+
cursor.next();
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
paragraphLines.push(content.trim());
|
|
985
|
+
cursor.next();
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
flushParagraph();
|
|
989
|
+
return { type: "blockquote", paragraphs, lineStart: bqStartLine, lineEnd: cursor.index };
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
function parseHeading(line) {
|
|
993
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
994
|
+
const raw = stripHeadingToken(trimmedLeft);
|
|
995
|
+
return parseHeadingText(raw);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function parseHeadingText(raw) {
|
|
999
|
+
const split = splitTrailingId(raw);
|
|
1000
|
+
return { title: split.text.trimEnd(), id: split.id ? split.id.slice(1) : undefined };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
function stripHeadingToken(line) {
|
|
1004
|
+
let i = 0;
|
|
1005
|
+
while (i < line.length && line[i] === "#") {
|
|
1006
|
+
i += 1;
|
|
1007
|
+
}
|
|
1008
|
+
return line.slice(i).trim();
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
function splitTrailingId(raw) {
|
|
1012
|
+
let i = raw.length - 1;
|
|
1013
|
+
while (i >= 0 && /\s/.test(raw[i])) {
|
|
1014
|
+
i -= 1;
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
const end = i;
|
|
1018
|
+
while (i >= 0 && isIdentChar(raw[i])) {
|
|
1019
|
+
i -= 1;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
if (i >= 0 && raw[i] === "@" && end > i && isIdentStart(raw[i + 1])) {
|
|
1023
|
+
if (!isEscaped(raw, i)) {
|
|
1024
|
+
if (i === 0 || /\s/.test(raw[i - 1])) {
|
|
1025
|
+
const id = raw.slice(i, end + 1);
|
|
1026
|
+
const text = raw.slice(0, i).trimEnd();
|
|
1027
|
+
return { text, id };
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
return { text: raw };
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
function isHeadingLine(line) {
|
|
1036
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
1037
|
+
if (trimmedLeft.startsWith("\\#")) {
|
|
1038
|
+
return false;
|
|
1039
|
+
}
|
|
1040
|
+
return trimmedLeft.startsWith(COMMAND_HEADING);
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function isBlockquoteLine(line) {
|
|
1044
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
1045
|
+
if (trimmedLeft.startsWith("\\>")) {
|
|
1046
|
+
return false;
|
|
1047
|
+
}
|
|
1048
|
+
return trimmedLeft.startsWith(">");
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function isHorizontalRule(trimmed) {
|
|
1052
|
+
const compact = trimmed.replace(/\s+/g, "");
|
|
1053
|
+
if (compact.length < 3) {
|
|
1054
|
+
return false;
|
|
1055
|
+
}
|
|
1056
|
+
if (!/^[-*_]+$/.test(compact)) {
|
|
1057
|
+
return false;
|
|
1058
|
+
}
|
|
1059
|
+
const char = compact[0];
|
|
1060
|
+
for (let i = 1; i < compact.length; i += 1) {
|
|
1061
|
+
if (compact[i] !== char) {
|
|
1062
|
+
return false;
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
return true;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
function isFenceStart(line) {
|
|
1069
|
+
const trimmedLeft = line.replace(/^\s+/, "");
|
|
1070
|
+
return trimmedLeft.startsWith(COMMAND_CODE_FENCE);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
function isIdentStart(ch) {
|
|
1074
|
+
return /[A-Za-z_]/.test(ch);
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function isIdentChar(ch) {
|
|
1078
|
+
return /[A-Za-z0-9_-]/.test(ch);
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
function isEscaped(text, index) {
|
|
1082
|
+
let count = 0;
|
|
1083
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
1084
|
+
if (text[i] === "\\") {
|
|
1085
|
+
count += 1;
|
|
1086
|
+
} else {
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return count % 2 === 1;
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function parseImageWidth(raw) {
|
|
1094
|
+
const match = raw.match(/^(.*)\s+=(\d+(?:\.\d+)?(?:%|px))(?:\s+(center|left|right))?$/);
|
|
1095
|
+
if (match) {
|
|
1096
|
+
const result = { src: match[1].trim(), width: match[2] };
|
|
1097
|
+
if (match[3]) result.align = match[3];
|
|
1098
|
+
return result;
|
|
1099
|
+
}
|
|
1100
|
+
return { src: raw };
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function parseInline(text) {
|
|
1104
|
+
const nodes = [];
|
|
1105
|
+
let buffer = "";
|
|
1106
|
+
let i = 0;
|
|
1107
|
+
|
|
1108
|
+
const flush = () => {
|
|
1109
|
+
if (buffer) {
|
|
1110
|
+
nodes.push({ type: "text", value: buffer });
|
|
1111
|
+
buffer = "";
|
|
1112
|
+
}
|
|
1113
|
+
};
|
|
1114
|
+
|
|
1115
|
+
while (i < text.length) {
|
|
1116
|
+
const ch = text[i];
|
|
1117
|
+
const next = text[i + 1];
|
|
1118
|
+
|
|
1119
|
+
if (ch === "\\" && next && ESCAPABLE.has(next)) {
|
|
1120
|
+
buffer += next;
|
|
1121
|
+
i += 2;
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
if (ch === "`") {
|
|
1126
|
+
const end = findUnescaped(text, i + 1, "`");
|
|
1127
|
+
if (end !== -1) {
|
|
1128
|
+
flush();
|
|
1129
|
+
nodes.push({ type: "code", value: text.slice(i + 1, end) });
|
|
1130
|
+
i = end + 1;
|
|
1131
|
+
continue;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
if (text.startsWith("**", i)) {
|
|
1136
|
+
const end = findUnescaped(text, i + 2, "**");
|
|
1137
|
+
if (end !== -1) {
|
|
1138
|
+
flush();
|
|
1139
|
+
const inner = parseInline(text.slice(i + 2, end));
|
|
1140
|
+
nodes.push({ type: "strong", children: inner });
|
|
1141
|
+
i = end + 2;
|
|
1142
|
+
continue;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
if (text.startsWith("~~", i)) {
|
|
1147
|
+
const end = findUnescaped(text, i + 2, "~~");
|
|
1148
|
+
if (end !== -1) {
|
|
1149
|
+
flush();
|
|
1150
|
+
const inner = parseInline(text.slice(i + 2, end));
|
|
1151
|
+
nodes.push({ type: "strike", children: inner });
|
|
1152
|
+
i = end + 2;
|
|
1153
|
+
continue;
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (ch === "*") {
|
|
1158
|
+
const end = findUnescaped(text, i + 1, "*");
|
|
1159
|
+
if (end !== -1) {
|
|
1160
|
+
flush();
|
|
1161
|
+
const inner = parseInline(text.slice(i + 1, end));
|
|
1162
|
+
nodes.push({ type: "em", children: inner });
|
|
1163
|
+
i = end + 1;
|
|
1164
|
+
continue;
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (ch === "!" && next === "[") {
|
|
1169
|
+
const endLabel = findUnescaped(text, i + 2, "]");
|
|
1170
|
+
if (endLabel !== -1 && text[endLabel + 1] === "(") {
|
|
1171
|
+
const endUrl = findUnescaped(text, endLabel + 2, ")");
|
|
1172
|
+
if (endUrl !== -1) {
|
|
1173
|
+
const label = text.slice(i + 2, endLabel);
|
|
1174
|
+
const rawUrl = text.slice(endLabel + 2, endUrl).trim();
|
|
1175
|
+
if (rawUrl) {
|
|
1176
|
+
flush();
|
|
1177
|
+
const { src: imgSrc, width: imgWidth, align: imgAlign } = parseImageWidth(rawUrl);
|
|
1178
|
+
const imgNode = { type: "image", alt: label, src: imgSrc };
|
|
1179
|
+
if (imgWidth) imgNode.width = imgWidth;
|
|
1180
|
+
if (imgAlign) imgNode.align = imgAlign;
|
|
1181
|
+
nodes.push(imgNode);
|
|
1182
|
+
i = endUrl + 1;
|
|
1183
|
+
continue;
|
|
1184
|
+
}
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (ch === "[") {
|
|
1190
|
+
const endLabel = findUnescaped(text, i + 1, "]");
|
|
1191
|
+
if (endLabel !== -1 && text[endLabel + 1] === "(") {
|
|
1192
|
+
const endUrl = findUnescaped(text, endLabel + 2, ")");
|
|
1193
|
+
if (endUrl !== -1) {
|
|
1194
|
+
const label = text.slice(i + 1, endLabel);
|
|
1195
|
+
const url = text.slice(endLabel + 2, endUrl).trim();
|
|
1196
|
+
if (url) {
|
|
1197
|
+
flush();
|
|
1198
|
+
nodes.push({ type: "link", href: url, children: parseInline(label) });
|
|
1199
|
+
i = endUrl + 1;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
if (ch === "<") {
|
|
1207
|
+
const end = findUnescaped(text, i + 1, ">");
|
|
1208
|
+
if (end !== -1) {
|
|
1209
|
+
const url = text.slice(i + 1, end).trim();
|
|
1210
|
+
if (/^(https?:\/\/|mailto:)/i.test(url)) {
|
|
1211
|
+
flush();
|
|
1212
|
+
nodes.push({ type: "link", href: url, children: [{ type: "text", value: url }] });
|
|
1213
|
+
i = end + 1;
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
|
|
1219
|
+
if (ch === "@" && next && isIdentStart(next)) {
|
|
1220
|
+
let j = i + 1;
|
|
1221
|
+
while (j < text.length && isIdentChar(text[j])) {
|
|
1222
|
+
j += 1;
|
|
1223
|
+
}
|
|
1224
|
+
const id = text.slice(i + 1, j);
|
|
1225
|
+
flush();
|
|
1226
|
+
nodes.push({ type: "ref", id });
|
|
1227
|
+
i = j;
|
|
1228
|
+
continue;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
buffer += ch;
|
|
1232
|
+
i += 1;
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
flush();
|
|
1236
|
+
return nodes;
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
function findUnescaped(text, start, token) {
|
|
1240
|
+
if (!token) {
|
|
1241
|
+
return -1;
|
|
1242
|
+
}
|
|
1243
|
+
for (let i = start; i <= text.length - token.length; i += 1) {
|
|
1244
|
+
if (text.startsWith(token, i) && !isEscaped(text, i)) {
|
|
1245
|
+
return i;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
return -1;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
let _renderOptions = {};
|
|
1252
|
+
|
|
1253
|
+
function dataLineAttrs(node) {
|
|
1254
|
+
if (node.lineStart == null) {
|
|
1255
|
+
return "";
|
|
1256
|
+
}
|
|
1257
|
+
let attrs = ` data-line="${node.lineStart}"`;
|
|
1258
|
+
if (node.lineEnd != null && node.lineEnd !== node.lineStart) {
|
|
1259
|
+
attrs += ` data-line-end="${node.lineEnd}"`;
|
|
1260
|
+
}
|
|
1261
|
+
return attrs;
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
function escapeHtml(value) {
|
|
1265
|
+
return value
|
|
1266
|
+
.replace(/&/g, "&")
|
|
1267
|
+
.replace(/</g, "<")
|
|
1268
|
+
.replace(/>/g, ">")
|
|
1269
|
+
.replace(/"/g, """);
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function escapeAttr(value) {
|
|
1273
|
+
return escapeHtml(value).replace(/'/g, "'");
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
function renderInline(text) {
|
|
1277
|
+
const nodes = parseInline(text);
|
|
1278
|
+
return renderInlineNodes(nodes);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
function renderInlineNodes(nodes) {
|
|
1282
|
+
return nodes
|
|
1283
|
+
.map((node) => {
|
|
1284
|
+
switch (node.type) {
|
|
1285
|
+
case "text":
|
|
1286
|
+
return escapeHtml(node.value);
|
|
1287
|
+
case "ref": {
|
|
1288
|
+
const href = `#${escapeAttr(node.id)}`;
|
|
1289
|
+
return `<a class="sdoc-ref" href="${href}">@${escapeHtml(node.id)}</a>`;
|
|
1290
|
+
}
|
|
1291
|
+
case "code":
|
|
1292
|
+
return `<code class="sdoc-inline-code">${escapeHtml(node.value)}</code>`;
|
|
1293
|
+
case "em":
|
|
1294
|
+
return `<em>${renderInlineNodes(node.children)}</em>`;
|
|
1295
|
+
case "strong":
|
|
1296
|
+
return `<strong>${renderInlineNodes(node.children)}</strong>`;
|
|
1297
|
+
case "strike":
|
|
1298
|
+
return `<del>${renderInlineNodes(node.children)}</del>`;
|
|
1299
|
+
case "link":
|
|
1300
|
+
return `<a class="sdoc-link" href="${escapeAttr(node.href)}" target="_blank" rel="noopener noreferrer">${renderInlineNodes(
|
|
1301
|
+
node.children
|
|
1302
|
+
)}</a>`;
|
|
1303
|
+
case "image": {
|
|
1304
|
+
const imgParts = [];
|
|
1305
|
+
if (node.width) imgParts.push(`width:${escapeAttr(node.width)}`);
|
|
1306
|
+
if (node.align === "center") imgParts.push("display:block", "margin-left:auto", "margin-right:auto");
|
|
1307
|
+
else if (node.align === "left") imgParts.push("display:block", "float:left", "margin-right:1rem");
|
|
1308
|
+
else if (node.align === "right") imgParts.push("display:block", "float:right", "margin-left:1rem");
|
|
1309
|
+
const imgStyle = imgParts.length ? ` style="${imgParts.join(";")}"` : "";
|
|
1310
|
+
return `<img class="sdoc-image" src="${escapeAttr(node.src)}" alt="${escapeAttr(node.alt)}"${imgStyle} />`;
|
|
1311
|
+
}
|
|
1312
|
+
default:
|
|
1313
|
+
return "";
|
|
1314
|
+
}
|
|
1315
|
+
})
|
|
1316
|
+
.join("");
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function renderScope(scope, depth, isTitleScope = false) {
|
|
1320
|
+
const level = Math.min(6, Math.max(1, depth));
|
|
1321
|
+
const children = scope.children.map((child) => renderNode(child, depth + 1)).join("\n");
|
|
1322
|
+
const rootClass = isTitleScope ? " sdoc-root" : "";
|
|
1323
|
+
const dl = dataLineAttrs(scope);
|
|
1324
|
+
|
|
1325
|
+
if (scope.hasHeading === false) {
|
|
1326
|
+
return `<section class="sdoc-scope sdoc-scope-noheading${rootClass}"${dl}>${children}</section>`;
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
|
|
1330
|
+
const hasChildren = scope.children.length > 0;
|
|
1331
|
+
const toggle = hasChildren ? `<span class="sdoc-toggle"></span>` : "";
|
|
1332
|
+
const heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${renderInline(scope.title)}</h${level}>`;
|
|
1333
|
+
const childrenHtml = children ? `\n<div class="sdoc-scope-children">${children}</div>` : "";
|
|
1334
|
+
return `<section class="sdoc-scope${rootClass}">${heading}${childrenHtml}</section>`;
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1337
|
+
function renderList(list, depth) {
|
|
1338
|
+
return renderListFromItems(list.listType, list.items, depth, list);
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
function renderListFromItems(listType, items, depth, list) {
|
|
1342
|
+
const tag = listType === "number" ? "ol" : "ul";
|
|
1343
|
+
const dl = list ? dataLineAttrs(list) : "";
|
|
1344
|
+
const renderedItems = items
|
|
1345
|
+
.map((item) => `<li class="sdoc-list-item">${renderListItem(item, listType, depth + 1)}</li>`)
|
|
1346
|
+
.join("\n");
|
|
1347
|
+
return `<${tag} class="sdoc-list sdoc-list-${listType}"${dl}>${renderedItems}</${tag}>`;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
function renderListItem(scope, listType, depth) {
|
|
1351
|
+
const level = Math.min(6, Math.max(1, depth));
|
|
1352
|
+
const task = scope.task ? scope.task : null;
|
|
1353
|
+
const hasHeading = scope.hasHeading !== false && (scope.title.trim() !== "" || task);
|
|
1354
|
+
const idAttr = scope.id ? ` id="${escapeAttr(scope.id)}"` : "";
|
|
1355
|
+
const dl = dataLineAttrs(scope);
|
|
1356
|
+
let headingInner = renderInline(scope.title);
|
|
1357
|
+
if (task) {
|
|
1358
|
+
const checked = task.checked ? " checked" : "";
|
|
1359
|
+
headingInner = `<span class="sdoc-task"><input class="sdoc-task-box" type="checkbox"${checked} disabled /><span class="sdoc-task-label">${headingInner}</span></span>`;
|
|
1360
|
+
}
|
|
1361
|
+
const isShorthand = scope.shorthand === true;
|
|
1362
|
+
const hasChildren = scope.children.length > 0;
|
|
1363
|
+
let heading;
|
|
1364
|
+
if (!hasHeading) {
|
|
1365
|
+
heading = "";
|
|
1366
|
+
} else if (isShorthand || !hasChildren) {
|
|
1367
|
+
heading = `<span${idAttr} class="sdoc-list-item-text"${dl}>${headingInner}</span>`;
|
|
1368
|
+
} else {
|
|
1369
|
+
const toggle = `<span class="sdoc-toggle"></span>`;
|
|
1370
|
+
heading = `<h${level}${idAttr} class="sdoc-heading sdoc-depth-${level}"${dl}>${toggle}${headingInner}</h${level}>`;
|
|
1371
|
+
}
|
|
1372
|
+
const bodyParts = [];
|
|
1373
|
+
let pendingScopes = [];
|
|
1374
|
+
|
|
1375
|
+
const flushPending = () => {
|
|
1376
|
+
if (!pendingScopes.length) {
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
bodyParts.push(renderListFromItems(listType, pendingScopes, depth + 1));
|
|
1380
|
+
pendingScopes = [];
|
|
1381
|
+
};
|
|
1382
|
+
|
|
1383
|
+
for (const child of scope.children) {
|
|
1384
|
+
if (child.type === "scope") {
|
|
1385
|
+
pendingScopes.push(child);
|
|
1386
|
+
continue;
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
flushPending();
|
|
1390
|
+
bodyParts.push(renderNode(child, depth + 1));
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
flushPending();
|
|
1394
|
+
|
|
1395
|
+
const body = bodyParts.join("\n");
|
|
1396
|
+
const bodyWrapper = body ? `\n<div class="sdoc-list-item-body sdoc-scope-children">${body}</div>` : "";
|
|
1397
|
+
const scopeClass = hasHeading ? "sdoc-scope" : "sdoc-scope sdoc-scope-noheading";
|
|
1398
|
+
return `<section class="${scopeClass}">${heading}${bodyWrapper}</section>`;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
function renderTable(table) {
|
|
1402
|
+
const dl = dataLineAttrs(table);
|
|
1403
|
+
const opts = table.options || {};
|
|
1404
|
+
const classes = ["sdoc-table"];
|
|
1405
|
+
if (opts.borderless) classes.push("sdoc-table-borderless");
|
|
1406
|
+
if (opts.headerless) classes.push("sdoc-table-headerless");
|
|
1407
|
+
const classAttr = classes.join(" ");
|
|
1408
|
+
|
|
1409
|
+
let thead = "";
|
|
1410
|
+
if (table.headers.length > 0) {
|
|
1411
|
+
const headerCells = table.headers
|
|
1412
|
+
.map((cell) => `<th class="sdoc-table-th">${renderInline(cell)}</th>`)
|
|
1413
|
+
.join("");
|
|
1414
|
+
thead = `<thead class="sdoc-table-head"><tr>${headerCells}</tr></thead>`;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
const bodyRows = table.rows
|
|
1418
|
+
.map((row) => {
|
|
1419
|
+
const cells = row
|
|
1420
|
+
.map((cell) => `<td class="sdoc-table-td">${renderInline(cell)}</td>`)
|
|
1421
|
+
.join("");
|
|
1422
|
+
return `<tr>${cells}</tr>`;
|
|
1423
|
+
})
|
|
1424
|
+
.join("\n");
|
|
1425
|
+
const tbody = bodyRows ? `<tbody class="sdoc-table-body">${bodyRows}</tbody>` : "";
|
|
1426
|
+
|
|
1427
|
+
return `<table class="${classAttr}"${dl}>${thead}${thead ? "\n" : ""}${tbody}</table>`;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function renderNode(node, depth) {
|
|
1431
|
+
const dl = dataLineAttrs(node);
|
|
1432
|
+
switch (node.type) {
|
|
1433
|
+
case "scope":
|
|
1434
|
+
return renderScope(node, depth);
|
|
1435
|
+
case "list":
|
|
1436
|
+
return renderList(node, depth);
|
|
1437
|
+
case "table":
|
|
1438
|
+
return renderTable(node);
|
|
1439
|
+
case "blockquote": {
|
|
1440
|
+
const paragraphs = node.paragraphs
|
|
1441
|
+
.map((text) => `<p class="sdoc-paragraph">${renderInline(text)}</p>`)
|
|
1442
|
+
.join("\n");
|
|
1443
|
+
return `<blockquote class="sdoc-blockquote"${dl}>${paragraphs}</blockquote>`;
|
|
1444
|
+
}
|
|
1445
|
+
case "hr":
|
|
1446
|
+
return `<hr class="sdoc-rule"${dl} />`;
|
|
1447
|
+
case "paragraph": {
|
|
1448
|
+
const editable = _renderOptions.editable ? ` contenteditable="true"` : "";
|
|
1449
|
+
return `<p class="sdoc-paragraph"${dl}${editable}>${renderInline(node.text)}</p>`;
|
|
1450
|
+
}
|
|
1451
|
+
case "code": {
|
|
1452
|
+
if (node.lang === "mermaid") {
|
|
1453
|
+
return `<pre class="mermaid"${dl}>${escapeHtml(node.text)}</pre>`;
|
|
1454
|
+
}
|
|
1455
|
+
const langClass = node.lang ? ` class="language-${escapeAttr(node.lang)}"` : "";
|
|
1456
|
+
return `<pre class="sdoc-code"${dl}><code${langClass}>${escapeHtml(node.text)}</code></pre>`;
|
|
1457
|
+
}
|
|
1458
|
+
default:
|
|
1459
|
+
return "";
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
function renderErrors(errors) {
|
|
1464
|
+
if (!errors.length) {
|
|
1465
|
+
return "";
|
|
1466
|
+
}
|
|
1467
|
+
const items = errors
|
|
1468
|
+
.map((error) => `<li>Line ${error.line}: ${escapeHtml(error.message)}</li>`)
|
|
1469
|
+
.join("\n");
|
|
1470
|
+
return `<aside class="sdoc-errors"><strong>SDOC parse warnings</strong><ul>${items}</ul></aside>`;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
function extractMeta(nodes) {
|
|
1474
|
+
const doc = getDocumentScope(nodes);
|
|
1475
|
+
const searchNodes = doc ? doc.children : nodes;
|
|
1476
|
+
|
|
1477
|
+
let metaIndex = -1;
|
|
1478
|
+
for (let i = 0; i < searchNodes.length; i += 1) {
|
|
1479
|
+
const node = searchNodes[i];
|
|
1480
|
+
if (node.type === "scope" && node.id && node.id.toLowerCase() === "meta") {
|
|
1481
|
+
metaIndex = i;
|
|
1482
|
+
break;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
if (metaIndex === -1) {
|
|
1487
|
+
return { nodes, meta: {} };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
const metaNode = searchNodes[metaIndex];
|
|
1491
|
+
const meta = {
|
|
1492
|
+
stylePath: null,
|
|
1493
|
+
styleAppendPath: null,
|
|
1494
|
+
headerNodes: null,
|
|
1495
|
+
footerNodes: null,
|
|
1496
|
+
headerText: null,
|
|
1497
|
+
footerText: null,
|
|
1498
|
+
properties: {}
|
|
1499
|
+
};
|
|
1500
|
+
|
|
1501
|
+
// First pass: sub-scope syntax (takes precedence)
|
|
1502
|
+
for (const child of metaNode.children) {
|
|
1503
|
+
if (child.type !== "scope") {
|
|
1504
|
+
continue;
|
|
1505
|
+
}
|
|
1506
|
+
const key = child.title.trim().toLowerCase();
|
|
1507
|
+
if (key === "style") {
|
|
1508
|
+
meta.stylePath = collectParagraphText(child.children);
|
|
1509
|
+
} else if (key === "styleappend" || key === "style-append") {
|
|
1510
|
+
meta.styleAppendPath = collectParagraphText(child.children);
|
|
1511
|
+
} else if (key === "header") {
|
|
1512
|
+
meta.headerNodes = child.children;
|
|
1513
|
+
} else if (key === "footer") {
|
|
1514
|
+
meta.footerNodes = child.children;
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Second pass: key:value syntax from paragraph nodes (only if not already set by sub-scope)
|
|
1519
|
+
const kvPattern = /^([\w][\w-]*)\s*:\s+(.+)$/;
|
|
1520
|
+
for (const child of metaNode.children) {
|
|
1521
|
+
if (child.type !== "paragraph") continue;
|
|
1522
|
+
const match = child.text.match(kvPattern);
|
|
1523
|
+
if (!match) continue;
|
|
1524
|
+
const key = match[1].toLowerCase();
|
|
1525
|
+
const value = match[2].trim();
|
|
1526
|
+
if (key === "style") {
|
|
1527
|
+
if (!meta.stylePath) meta.stylePath = value;
|
|
1528
|
+
} else if (key === "styleappend" || key === "style-append") {
|
|
1529
|
+
if (!meta.styleAppendPath) meta.styleAppendPath = value;
|
|
1530
|
+
} else if (key === "header") {
|
|
1531
|
+
if (!meta.headerNodes && !meta.headerText) meta.headerText = value;
|
|
1532
|
+
} else if (key === "footer") {
|
|
1533
|
+
if (!meta.footerNodes && !meta.footerText) meta.footerText = value;
|
|
1534
|
+
} else {
|
|
1535
|
+
if (!(key in meta.properties)) meta.properties[key] = value;
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
// Promote well-known Lexica properties
|
|
1540
|
+
meta.uuid = meta.properties.uuid || null;
|
|
1541
|
+
meta.type = meta.properties.type || null;
|
|
1542
|
+
meta.tags = meta.properties.tags
|
|
1543
|
+
? meta.properties.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
|
1544
|
+
: [];
|
|
1545
|
+
meta.company = meta.properties.company || null;
|
|
1546
|
+
meta.confidential = meta.properties.confidential || null;
|
|
1547
|
+
|
|
1548
|
+
if (doc) {
|
|
1549
|
+
// @meta was inside the document scope — strip it from children
|
|
1550
|
+
const filteredChildren = doc.children.filter((_, index) => index !== metaIndex);
|
|
1551
|
+
const stripped = { ...doc, children: filteredChildren };
|
|
1552
|
+
return { nodes: [stripped], meta };
|
|
1553
|
+
}
|
|
1554
|
+
const bodyNodes = nodes.filter((_, index) => index !== metaIndex);
|
|
1555
|
+
return { nodes: bodyNodes, meta };
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
function collectParagraphText(nodes) {
|
|
1559
|
+
return nodes
|
|
1560
|
+
.filter((node) => node.type === "paragraph")
|
|
1561
|
+
.map((node) => node.text)
|
|
1562
|
+
.join("\n")
|
|
1563
|
+
.trim();
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
function renderTextParagraphs(text) {
|
|
1567
|
+
if (!text) {
|
|
1568
|
+
return "";
|
|
1569
|
+
}
|
|
1570
|
+
const paragraphs = text
|
|
1571
|
+
.split(/\n\s*\n/)
|
|
1572
|
+
.map((chunk) => chunk.trim())
|
|
1573
|
+
.filter(Boolean);
|
|
1574
|
+
return paragraphs.map((chunk) => `<p class="sdoc-paragraph">${renderInline(chunk)}</p>`).join("\n");
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
function renderFragment(nodes, depth = 2) {
|
|
1578
|
+
return nodes.map((node) => renderNode(node, depth)).join("\n");
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
function buildConfidentialHtml(meta) {
|
|
1582
|
+
if (!meta || !meta.confidential) return "";
|
|
1583
|
+
const val = meta.confidential.trim();
|
|
1584
|
+
if (!val) return "";
|
|
1585
|
+
const entity = val.toLowerCase() === "true" ? meta.company : val;
|
|
1586
|
+
const text = entity
|
|
1587
|
+
? `CONFIDENTIAL \u2014 ${escapeHtml(entity)}`
|
|
1588
|
+
: "CONFIDENTIAL";
|
|
1589
|
+
return `<div class="sdoc-confidential-notice">${text}</div>`;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
const DEFAULT_STYLE = `
|
|
1593
|
+
:root {
|
|
1594
|
+
--sdoc-bg: #ffffff;
|
|
1595
|
+
--sdoc-fg: #2a2a2a;
|
|
1596
|
+
--sdoc-muted: #555555;
|
|
1597
|
+
--sdoc-accent: #c1662f;
|
|
1598
|
+
--sdoc-accent-soft: rgba(193, 102, 47, 0.12);
|
|
1599
|
+
--sdoc-border: rgba(127, 120, 112, 0.35);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
body {
|
|
1603
|
+
margin: 0;
|
|
1604
|
+
color: var(--sdoc-fg);
|
|
1605
|
+
font-family: "Source Sans 3", "Noto Sans", "Segoe UI", "Helvetica Neue", "Arial", sans-serif;
|
|
1606
|
+
background: var(--sdoc-bg);
|
|
1607
|
+
height: 100vh;
|
|
1608
|
+
overflow: hidden;
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
.sdoc-shell {
|
|
1612
|
+
display: flex;
|
|
1613
|
+
flex-direction: column;
|
|
1614
|
+
height: 100%;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
.sdoc-page-header,
|
|
1618
|
+
.sdoc-page-footer {
|
|
1619
|
+
background: var(--sdoc-bg);
|
|
1620
|
+
border-bottom: 1px solid var(--sdoc-border);
|
|
1621
|
+
padding: 16px 24px;
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
.sdoc-page-footer {
|
|
1625
|
+
border-top: 1px solid var(--sdoc-border);
|
|
1626
|
+
border-bottom: none;
|
|
1627
|
+
color: var(--sdoc-muted);
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
.sdoc-confidential-notice {
|
|
1631
|
+
background: rgba(187, 68, 68, 0.08);
|
|
1632
|
+
border-bottom: 1px solid rgba(187, 68, 68, 0.2);
|
|
1633
|
+
color: rgba(160, 40, 40, 0.85);
|
|
1634
|
+
text-align: center;
|
|
1635
|
+
padding: 5px 24px;
|
|
1636
|
+
font-size: 0.72rem;
|
|
1637
|
+
font-weight: 600;
|
|
1638
|
+
letter-spacing: 0.12em;
|
|
1639
|
+
text-transform: uppercase;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
.sdoc-main {
|
|
1643
|
+
flex: 1;
|
|
1644
|
+
overflow: auto;
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
main {
|
|
1648
|
+
width: 100%;
|
|
1649
|
+
max-width: none;
|
|
1650
|
+
margin: 0;
|
|
1651
|
+
padding: clamp(20px, 2.5vw, 36px) clamp(24px, 4vw, 72px) clamp(28px, 4vw, 56px);
|
|
1652
|
+
box-sizing: border-box;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
.sdoc-heading {
|
|
1656
|
+
margin: 1.6rem 0 0.5rem;
|
|
1657
|
+
font-weight: 700;
|
|
1658
|
+
letter-spacing: 0.01em;
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
.sdoc-depth-1 { font-size: 2.2rem; border-bottom: 2px solid var(--sdoc-border); padding-bottom: 0.4rem; }
|
|
1662
|
+
.sdoc-depth-2 { font-size: 1.8rem; color: var(--sdoc-fg); }
|
|
1663
|
+
.sdoc-depth-3 { font-size: 1.5rem; color: var(--sdoc-fg); }
|
|
1664
|
+
.sdoc-depth-4 { font-size: 1.2rem; color: var(--sdoc-muted); letter-spacing: 0.04em; }
|
|
1665
|
+
.sdoc-depth-5, .sdoc-depth-6 { font-size: 1rem; color: var(--sdoc-muted); letter-spacing: 0.04em; }
|
|
1666
|
+
|
|
1667
|
+
.sdoc-paragraph {
|
|
1668
|
+
margin: 0.6rem 0;
|
|
1669
|
+
line-height: 1.6;
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1672
|
+
.sdoc-scope-children > .sdoc-scope {
|
|
1673
|
+
padding-left: 1.5rem;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
.sdoc-list-item-body > .sdoc-paragraph:first-child {
|
|
1677
|
+
margin-top: 0.2rem;
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
.sdoc-scope-noheading .sdoc-list-item-body > .sdoc-paragraph:first-child {
|
|
1681
|
+
margin-top: 0;
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
.sdoc-blockquote {
|
|
1685
|
+
border-left: 3px solid var(--sdoc-border);
|
|
1686
|
+
padding: 0.4rem 1rem;
|
|
1687
|
+
margin: 1rem 0;
|
|
1688
|
+
color: var(--sdoc-muted);
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
.sdoc-blockquote .sdoc-paragraph {
|
|
1692
|
+
margin: 0.4rem 0;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
.sdoc-rule {
|
|
1696
|
+
border: none;
|
|
1697
|
+
border-top: 1px solid var(--sdoc-border);
|
|
1698
|
+
margin: 1.4rem 0;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
.sdoc-table {
|
|
1702
|
+
border-collapse: separate;
|
|
1703
|
+
border-spacing: 0;
|
|
1704
|
+
width: 100%;
|
|
1705
|
+
margin: 1rem 0;
|
|
1706
|
+
border: 1px solid var(--sdoc-border);
|
|
1707
|
+
border-radius: 10px;
|
|
1708
|
+
overflow: hidden;
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
.sdoc-table-th {
|
|
1712
|
+
background: rgba(0, 0, 0, 0.06);
|
|
1713
|
+
font-weight: 600;
|
|
1714
|
+
text-align: left;
|
|
1715
|
+
padding: 10px 14px;
|
|
1716
|
+
border-bottom: 1px solid var(--sdoc-border);
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
.sdoc-table-td {
|
|
1720
|
+
padding: 8px 14px;
|
|
1721
|
+
border-bottom: 1px solid rgba(127, 120, 112, 0.15);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
.sdoc-table-body tr:nth-child(even) {
|
|
1725
|
+
background: rgba(0, 0, 0, 0.025);
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
.sdoc-table-body tr:last-child .sdoc-table-td {
|
|
1729
|
+
border-bottom: none;
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
.sdoc-table-borderless,
|
|
1733
|
+
.sdoc-table-borderless th,
|
|
1734
|
+
.sdoc-table-borderless td {
|
|
1735
|
+
border: none;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
.sdoc-table-borderless tr:nth-child(even) td {
|
|
1739
|
+
background: none;
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
.sdoc-task {
|
|
1743
|
+
display: inline-flex;
|
|
1744
|
+
align-items: center;
|
|
1745
|
+
gap: 0.5rem;
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
.sdoc-task-box {
|
|
1749
|
+
width: 1rem;
|
|
1750
|
+
height: 1rem;
|
|
1751
|
+
margin: 0;
|
|
1752
|
+
accent-color: var(--sdoc-accent);
|
|
1753
|
+
}
|
|
1754
|
+
|
|
1755
|
+
.sdoc-task-label {
|
|
1756
|
+
display: inline-block;
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
.sdoc-list {
|
|
1760
|
+
margin: 0.8rem 0 0.8rem;
|
|
1761
|
+
}
|
|
1762
|
+
|
|
1763
|
+
.sdoc-list-bullet {
|
|
1764
|
+
padding-left: 1.4rem;
|
|
1765
|
+
list-style: disc;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
.sdoc-list-bullet .sdoc-list-bullet {
|
|
1769
|
+
list-style: circle;
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
.sdoc-list-number {
|
|
1773
|
+
list-style: none;
|
|
1774
|
+
padding-left: 0;
|
|
1775
|
+
margin-left: 0;
|
|
1776
|
+
counter-reset: sdoc-item;
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
.sdoc-list-number .sdoc-list-number {
|
|
1780
|
+
margin-left: 1.4rem;
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
.sdoc-list-number > .sdoc-list-item {
|
|
1784
|
+
counter-increment: sdoc-item;
|
|
1785
|
+
position: relative;
|
|
1786
|
+
padding-left: 2rem;
|
|
1787
|
+
margin: 0.4rem 0 0.8rem;
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
.sdoc-list-number > .sdoc-list-item::before {
|
|
1791
|
+
content: counters(sdoc-item, ".") ".";
|
|
1792
|
+
position: absolute;
|
|
1793
|
+
left: 0;
|
|
1794
|
+
top: 0.2rem;
|
|
1795
|
+
color: var(--sdoc-muted);
|
|
1796
|
+
font-weight: 600;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
.sdoc-list-item {
|
|
1800
|
+
margin: 0.4rem 0 0.8rem;
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
.sdoc-list-item-text {
|
|
1804
|
+
line-height: 1.6;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
.sdoc-ref {
|
|
1808
|
+
color: var(--sdoc-accent);
|
|
1809
|
+
text-decoration: none;
|
|
1810
|
+
border-bottom: 1px solid var(--sdoc-accent-soft);
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
.sdoc-ref:hover {
|
|
1814
|
+
border-bottom-color: var(--sdoc-accent);
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1817
|
+
.sdoc-link {
|
|
1818
|
+
color: var(--sdoc-accent);
|
|
1819
|
+
text-decoration: underline;
|
|
1820
|
+
text-decoration-color: var(--sdoc-accent-soft);
|
|
1821
|
+
text-underline-offset: 2px;
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
.sdoc-link:hover {
|
|
1825
|
+
text-decoration-color: var(--sdoc-accent);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
.sdoc-inline-code {
|
|
1829
|
+
font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", monospace;
|
|
1830
|
+
font-size: 0.95em;
|
|
1831
|
+
background: rgba(0, 0, 0, 0.06);
|
|
1832
|
+
border: 1px solid var(--sdoc-border);
|
|
1833
|
+
border-radius: 4px;
|
|
1834
|
+
padding: 0 0.2em;
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
.sdoc-image {
|
|
1838
|
+
display: inline-block;
|
|
1839
|
+
max-width: 100%;
|
|
1840
|
+
border-radius: 10px;
|
|
1841
|
+
border: 1px solid var(--sdoc-border);
|
|
1842
|
+
margin: 0.4rem 0;
|
|
1843
|
+
vertical-align: top;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
.sdoc-image + .sdoc-image {
|
|
1847
|
+
margin-left: 0.5%;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
.sdoc-code {
|
|
1851
|
+
background: rgba(22, 21, 19, 0.06);
|
|
1852
|
+
border: 1px solid var(--sdoc-border);
|
|
1853
|
+
border-radius: 10px;
|
|
1854
|
+
padding: 12px 14px;
|
|
1855
|
+
overflow-x: auto;
|
|
1856
|
+
font-family: "JetBrains Mono", "Fira Code", "Source Code Pro", monospace;
|
|
1857
|
+
font-size: 0.9rem;
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
.sdoc-code code {
|
|
1861
|
+
background: none;
|
|
1862
|
+
border: none;
|
|
1863
|
+
padding: 0;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
.sdoc-errors {
|
|
1867
|
+
background: rgba(187, 112, 68, 0.12);
|
|
1868
|
+
border: 1px solid rgba(187, 112, 68, 0.4);
|
|
1869
|
+
padding: 12px 16px;
|
|
1870
|
+
border-radius: 10px;
|
|
1871
|
+
margin-bottom: 1.5rem;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
.sdoc-errors ul {
|
|
1875
|
+
margin: 0.6rem 0 0;
|
|
1876
|
+
padding-left: 1.2rem;
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
@media print {
|
|
1880
|
+
body {
|
|
1881
|
+
height: auto;
|
|
1882
|
+
overflow: visible;
|
|
1883
|
+
}
|
|
1884
|
+
.sdoc-shell {
|
|
1885
|
+
height: auto;
|
|
1886
|
+
}
|
|
1887
|
+
.sdoc-main {
|
|
1888
|
+
overflow: visible;
|
|
1889
|
+
}
|
|
1890
|
+
.sdoc-code {
|
|
1891
|
+
white-space: pre-wrap;
|
|
1892
|
+
word-wrap: break-word;
|
|
1893
|
+
overflow-x: visible;
|
|
1894
|
+
}
|
|
1895
|
+
.sdoc-code code {
|
|
1896
|
+
white-space: pre-wrap;
|
|
1897
|
+
word-wrap: break-word;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
`;
|
|
1901
|
+
|
|
1902
|
+
const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js";
|
|
1903
|
+
|
|
1904
|
+
function hasMermaidBlocks(nodes) {
|
|
1905
|
+
for (const node of nodes) {
|
|
1906
|
+
if (node.type === "code" && node.lang === "mermaid") return true;
|
|
1907
|
+
if (node.children && hasMermaidBlocks(node.children)) return true;
|
|
1908
|
+
if (node.items) {
|
|
1909
|
+
for (const item of node.items) {
|
|
1910
|
+
if (item.children && hasMermaidBlocks(item.children)) return true;
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
return false;
|
|
1915
|
+
}
|
|
1916
|
+
|
|
1917
|
+
function renderHtmlDocumentFromParsed(parsed, title, options = {}) {
|
|
1918
|
+
_renderOptions = options.renderOptions ?? {};
|
|
1919
|
+
const body = parsed.nodes
|
|
1920
|
+
.map((node, index) => {
|
|
1921
|
+
if (node.type === "scope" && index === 0) {
|
|
1922
|
+
return renderScope(node, 1, true);
|
|
1923
|
+
}
|
|
1924
|
+
return renderNode(node, 1);
|
|
1925
|
+
})
|
|
1926
|
+
.join("\n");
|
|
1927
|
+
_renderOptions = {};
|
|
1928
|
+
const errorHtml = renderErrors(parsed.errors);
|
|
1929
|
+
|
|
1930
|
+
const meta = options.meta ?? {};
|
|
1931
|
+
const config = options.config ?? {};
|
|
1932
|
+
const confidentialHtml = buildConfidentialHtml(meta);
|
|
1933
|
+
const headerHtml = meta.headerNodes ? renderFragment(meta.headerNodes, 2)
|
|
1934
|
+
: meta.headerText ? renderTextParagraphs(meta.headerText)
|
|
1935
|
+
: renderTextParagraphs(config.header);
|
|
1936
|
+
const companyHtml = meta.company
|
|
1937
|
+
? `<span class="sdoc-company-footer">${escapeHtml(meta.company)}</span>`
|
|
1938
|
+
: "";
|
|
1939
|
+
const footerHtml = meta.footerNodes ? renderFragment(meta.footerNodes, 2)
|
|
1940
|
+
: meta.footerText ? renderTextParagraphs(meta.footerText)
|
|
1941
|
+
: renderTextParagraphs(config.footer);
|
|
1942
|
+
const footerContent = [footerHtml, companyHtml].filter(Boolean).join("\n");
|
|
1943
|
+
|
|
1944
|
+
const cssBase = options.cssOverride ?? DEFAULT_STYLE;
|
|
1945
|
+
const cssAppend = options.cssAppend ? `\n${options.cssAppend}` : "";
|
|
1946
|
+
const scriptTag = options.script ? `\n<script>${options.script}</script>` : "";
|
|
1947
|
+
const mermaidScript = hasMermaidBlocks(parsed.nodes)
|
|
1948
|
+
? `\n<script src="${MERMAID_CDN}"></script>\n<script>mermaid.initialize({startOnLoad:true,theme:"neutral",themeCSS:".node rect, .node polygon, .node circle { rx: 4; ry: 4; }"});</script>`
|
|
1949
|
+
: "";
|
|
1950
|
+
|
|
1951
|
+
return `<!DOCTYPE html>
|
|
1952
|
+
<html lang="en">
|
|
1953
|
+
<head>
|
|
1954
|
+
<meta charset="UTF-8" />
|
|
1955
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
1956
|
+
<title>${escapeHtml(title)}</title>
|
|
1957
|
+
<style>
|
|
1958
|
+
${cssBase}${cssAppend}
|
|
1959
|
+
</style>
|
|
1960
|
+
</head>
|
|
1961
|
+
<body>
|
|
1962
|
+
<div class="sdoc-shell">
|
|
1963
|
+
${confidentialHtml}
|
|
1964
|
+
${headerHtml ? `<header class="sdoc-page-header">${headerHtml}</header>` : ""}
|
|
1965
|
+
<div class="sdoc-main">
|
|
1966
|
+
<main>
|
|
1967
|
+
${errorHtml}
|
|
1968
|
+
${body}
|
|
1969
|
+
</main>
|
|
1970
|
+
</div>
|
|
1971
|
+
${footerContent ? `<footer class="sdoc-page-footer">${footerContent}</footer>` : ""}
|
|
1972
|
+
</div>${scriptTag}${mermaidScript}
|
|
1973
|
+
</body>
|
|
1974
|
+
</html>`;
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function renderHtmlDocument(text, title, options = {}) {
|
|
1978
|
+
const parsed = parseSdoc(text);
|
|
1979
|
+
const metaResult = extractMeta(parsed.nodes);
|
|
1980
|
+
return renderHtmlDocumentFromParsed({ nodes: metaResult.nodes, errors: parsed.errors }, title, {
|
|
1981
|
+
...options,
|
|
1982
|
+
meta: metaResult.meta
|
|
1983
|
+
});
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
function formatSdoc(text, indentStr = " ") {
|
|
1987
|
+
const normalized = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
1988
|
+
const lines = normalized.split("\n");
|
|
1989
|
+
const result = [];
|
|
1990
|
+
let depth = 0;
|
|
1991
|
+
let inCodeBlock = false;
|
|
1992
|
+
let codeFenceLen = 0;
|
|
1993
|
+
|
|
1994
|
+
for (const line of lines) {
|
|
1995
|
+
const trimmed = line.trim();
|
|
1996
|
+
|
|
1997
|
+
// Blank lines — emit empty, no depth change
|
|
1998
|
+
if (trimmed === "") {
|
|
1999
|
+
result.push("");
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
// Inside code block — pass through raw
|
|
2004
|
+
if (inCodeBlock) {
|
|
2005
|
+
const closeMatch = trimmed.match(/^(`{3,})\s*$/);
|
|
2006
|
+
if (closeMatch && closeMatch[1].length >= codeFenceLen) {
|
|
2007
|
+
inCodeBlock = false;
|
|
2008
|
+
codeFenceLen = 0;
|
|
2009
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2010
|
+
} else {
|
|
2011
|
+
result.push(line);
|
|
2012
|
+
}
|
|
2013
|
+
continue;
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
// Code fence opening
|
|
2017
|
+
if (isFenceStart(trimmed)) {
|
|
2018
|
+
const openMatch = trimmed.match(/^(`{3,})/);
|
|
2019
|
+
codeFenceLen = openMatch ? openMatch[1].length : 3;
|
|
2020
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2021
|
+
inCodeBlock = true;
|
|
2022
|
+
continue;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// Closing brace — decrement first, then indent
|
|
2026
|
+
if (trimmed === COMMAND_SCOPE_CLOSE) {
|
|
2027
|
+
depth = Math.max(0, depth - 1);
|
|
2028
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2029
|
+
continue;
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
// Inline block { content }
|
|
2033
|
+
if (tryParseInlineBlock(trimmed) !== null) {
|
|
2034
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2035
|
+
continue;
|
|
2036
|
+
}
|
|
2037
|
+
|
|
2038
|
+
// Standalone opener: {, {[.], {[#], {[table]
|
|
2039
|
+
if (trimmed === COMMAND_SCOPE_OPEN ||
|
|
2040
|
+
trimmed === COMMAND_LIST_BULLET ||
|
|
2041
|
+
trimmed === COMMAND_LIST_NUMBER ||
|
|
2042
|
+
isTableCommand(trimmed)) {
|
|
2043
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2044
|
+
depth++;
|
|
2045
|
+
continue;
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
// K&R line — heading or list item ending with opener
|
|
2049
|
+
const trailing = extractTrailingOpener(trimmed);
|
|
2050
|
+
if (trailing) {
|
|
2051
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2052
|
+
depth++;
|
|
2053
|
+
continue;
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
// Everything else — indent at current depth
|
|
2057
|
+
result.push(indentStr.repeat(depth) + trimmed);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
return result.join("\n");
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
// --- Lexica utility functions ---
|
|
2064
|
+
|
|
2065
|
+
const KNOWN_TYPES = ["skill", "doc"];
|
|
2066
|
+
|
|
2067
|
+
function inferType(filename, meta) {
|
|
2068
|
+
if (meta && meta.type) return meta.type;
|
|
2069
|
+
const base = filename.replace(/\.sdoc$/i, "");
|
|
2070
|
+
const prefix = base.split("-")[0].toLowerCase();
|
|
2071
|
+
if (KNOWN_TYPES.includes(prefix)) return prefix;
|
|
2072
|
+
return null;
|
|
2073
|
+
}
|
|
2074
|
+
|
|
2075
|
+
function slugify(text) {
|
|
2076
|
+
return text
|
|
2077
|
+
.replace(/[*~`_]/g, "")
|
|
2078
|
+
.toLowerCase()
|
|
2079
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
2080
|
+
.replace(/^-+|-+$/g, "");
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
function getDocumentScope(nodes) {
|
|
2084
|
+
if (nodes.length === 1 && nodes[0].type === "scope" && nodes[0].children) {
|
|
2085
|
+
return nodes[0];
|
|
2086
|
+
}
|
|
2087
|
+
return null;
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
function getContentScopes(nodes) {
|
|
2091
|
+
const doc = getDocumentScope(nodes);
|
|
2092
|
+
const children = doc ? doc.children : nodes;
|
|
2093
|
+
return children.filter(
|
|
2094
|
+
(n) => n.type === "scope" && (!n.id || (n.id.toLowerCase() !== "meta" && n.id.toLowerCase() !== "about"))
|
|
2095
|
+
);
|
|
2096
|
+
}
|
|
2097
|
+
|
|
2098
|
+
function collectPlainText(nodes) {
|
|
2099
|
+
const parts = [];
|
|
2100
|
+
for (const node of nodes) {
|
|
2101
|
+
if (node.type === "paragraph") {
|
|
2102
|
+
parts.push(node.text);
|
|
2103
|
+
} else if (node.type === "list") {
|
|
2104
|
+
for (const item of node.items || []) {
|
|
2105
|
+
if (item.title) parts.push(item.title);
|
|
2106
|
+
if (item.children) parts.push(collectPlainText(item.children));
|
|
2107
|
+
}
|
|
2108
|
+
} else if (node.type === "scope" && node.children) {
|
|
2109
|
+
parts.push(collectPlainText(node.children));
|
|
2110
|
+
} else if (node.type === "code" && node.content) {
|
|
2111
|
+
parts.push(node.content);
|
|
2112
|
+
} else if (node.type === "blockquote" && node.text) {
|
|
2113
|
+
parts.push(node.text);
|
|
2114
|
+
} else if (node.type === "table") {
|
|
2115
|
+
if (node.headers) parts.push(node.headers.join(" | "));
|
|
2116
|
+
if (node.rows) {
|
|
2117
|
+
for (const row of node.rows) parts.push(row.join(" | "));
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
return parts.filter(Boolean).join("\n\n");
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
function firstParagraphPreview(nodes, maxLen) {
|
|
2125
|
+
for (const node of nodes) {
|
|
2126
|
+
if (node.type === "paragraph" && node.text) {
|
|
2127
|
+
const text = node.text.trim();
|
|
2128
|
+
if (text.length <= maxLen) return text;
|
|
2129
|
+
const truncated = text.substring(0, maxLen);
|
|
2130
|
+
const lastSpace = truncated.lastIndexOf(" ");
|
|
2131
|
+
return (lastSpace > maxLen * 0.5 ? truncated.substring(0, lastSpace) : truncated) + "...";
|
|
2132
|
+
}
|
|
2133
|
+
}
|
|
2134
|
+
return "";
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
function listSections(nodes) {
|
|
2138
|
+
return getContentScopes(nodes).map((node) => ({
|
|
2139
|
+
id: node.id || null,
|
|
2140
|
+
derivedId: slugify(node.title),
|
|
2141
|
+
title: node.title,
|
|
2142
|
+
preview: firstParagraphPreview(node.children || [], 100)
|
|
2143
|
+
}));
|
|
2144
|
+
}
|
|
2145
|
+
|
|
2146
|
+
function extractSection(nodes, sectionId) {
|
|
2147
|
+
const scopes = getContentScopes(nodes);
|
|
2148
|
+
|
|
2149
|
+
// First pass: match explicit @id (case-sensitive)
|
|
2150
|
+
for (const node of scopes) {
|
|
2151
|
+
if (node.id && node.id === sectionId) {
|
|
2152
|
+
return { title: node.title, content: collectPlainText(node.children || []) };
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
// Second pass: match derived slug (case-insensitive)
|
|
2157
|
+
const lowerTarget = sectionId.toLowerCase();
|
|
2158
|
+
for (const node of scopes) {
|
|
2159
|
+
if (slugify(node.title).toLowerCase() === lowerTarget) {
|
|
2160
|
+
return { title: node.title, content: collectPlainText(node.children || []) };
|
|
2161
|
+
}
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
return null;
|
|
2165
|
+
}
|
|
2166
|
+
|
|
2167
|
+
function extractAbout(nodes) {
|
|
2168
|
+
const doc = getDocumentScope(nodes);
|
|
2169
|
+
const children = doc ? doc.children : nodes;
|
|
2170
|
+
|
|
2171
|
+
for (const node of children) {
|
|
2172
|
+
if (node.type === "scope" && node.id && node.id.toLowerCase() === "about") {
|
|
2173
|
+
const texts = (node.children || [])
|
|
2174
|
+
.filter((c) => c.type === "paragraph")
|
|
2175
|
+
.map((c) => c.text.trim());
|
|
2176
|
+
return texts.length ? texts.join(" ") : null;
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return null;
|
|
2180
|
+
}
|
|
2181
|
+
|
|
2182
|
+
async function resolveIncludes(nodes, resolverFn) {
|
|
2183
|
+
for (const node of nodes) {
|
|
2184
|
+
if (node.type === "code" && node.src) {
|
|
2185
|
+
try {
|
|
2186
|
+
let text = await resolverFn(node.src);
|
|
2187
|
+
if (node.lines) {
|
|
2188
|
+
const allLines = text.split("\n");
|
|
2189
|
+
text = allLines.slice(node.lines.start - 1, node.lines.end).join("\n");
|
|
2190
|
+
}
|
|
2191
|
+
node.text = text;
|
|
2192
|
+
} catch (err) {
|
|
2193
|
+
node.text = `// Error: Could not read ${node.src} — ${err.message}`;
|
|
2194
|
+
}
|
|
2195
|
+
}
|
|
2196
|
+
if (node.children) {
|
|
2197
|
+
await resolveIncludes(node.children, resolverFn);
|
|
2198
|
+
}
|
|
2199
|
+
if (node.items) {
|
|
2200
|
+
await resolveIncludes(node.items, resolverFn);
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
}
|
|
2204
|
+
|
|
2205
|
+
module.exports = {
|
|
2206
|
+
parseSdoc,
|
|
2207
|
+
extractMeta,
|
|
2208
|
+
resolveIncludes,
|
|
2209
|
+
renderFragment,
|
|
2210
|
+
renderTextParagraphs,
|
|
2211
|
+
renderHtmlDocumentFromParsed,
|
|
2212
|
+
renderHtmlDocument,
|
|
2213
|
+
formatSdoc,
|
|
2214
|
+
slugify,
|
|
2215
|
+
inferType,
|
|
2216
|
+
listSections,
|
|
2217
|
+
extractSection,
|
|
2218
|
+
extractAbout,
|
|
2219
|
+
// Low-level helpers for custom renderers (e.g. slide-renderer)
|
|
2220
|
+
parseInline,
|
|
2221
|
+
escapeHtml,
|
|
2222
|
+
escapeAttr
|
|
2223
|
+
};
|