@hraness/wordcell 0.22.2 → 0.22.4
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 +4 -4
- package/dist/cli.js +4 -4
- package/dist/{index-3agn8scn.js → index-23m6bbjt.js} +47 -11
- package/dist/{index-bw2r37z9.js → index-9h7zh0wt.js} +1 -1
- package/dist/{index-hmw17zaa.js → index-pnt9wr59.js} +516 -230
- package/dist/index.js +2 -2
- package/dist/publish-reader/reader.js +48 -12
- package/dist/publish-search.js +1 -1
- package/dist/publish.js +2 -2
- package/dist/{support-p452p9c4.js → support-m3t917ev.js} +3 -3
- package/package.json +3 -3
- package/skills/wordcell/SKILL.md +1 -1
- package/src/publish-index.ts +3 -2
- package/src/publish-markdown.ts +400 -122
- package/src/publish-model.ts +2 -2
- package/src/publish-reader/reader.ts +3 -1
- package/src/publish-search.ts +44 -13
- package/src/publish.ts +4 -1
package/src/publish-markdown.ts
CHANGED
|
@@ -41,9 +41,15 @@ function anchorId(text: string, used: Map<string, number>): string {
|
|
|
41
41
|
.toLocaleLowerCase("en-US")
|
|
42
42
|
.replace(VOID_SLUG, "-")
|
|
43
43
|
.replace(/^[-._~]+|[-._~]+$/gu, "") || "section";
|
|
44
|
-
|
|
44
|
+
let seen = used.get(base) ?? 0;
|
|
45
|
+
let id = seen === 0 ? base : `${base}-${seen + 1}`;
|
|
46
|
+
while (used.has(id)) {
|
|
47
|
+
seen += 1;
|
|
48
|
+
id = `${base}-${seen + 1}`;
|
|
49
|
+
}
|
|
45
50
|
used.set(base, seen + 1);
|
|
46
|
-
|
|
51
|
+
used.set(id, 1);
|
|
52
|
+
return id;
|
|
47
53
|
}
|
|
48
54
|
|
|
49
55
|
function isExternalUrl(value: string): boolean {
|
|
@@ -80,7 +86,63 @@ function wikiDisplay(raw: string): { target: string; fragment: string; label: st
|
|
|
80
86
|
};
|
|
81
87
|
}
|
|
82
88
|
|
|
83
|
-
|
|
89
|
+
const FOOTNOTE_LIMITS = {
|
|
90
|
+
labelBytes: 128,
|
|
91
|
+
definitions: 256,
|
|
92
|
+
bodyBytes: 8_192,
|
|
93
|
+
bodyLines: 32,
|
|
94
|
+
references: 2_048,
|
|
95
|
+
} as const;
|
|
96
|
+
|
|
97
|
+
type Footnote = {
|
|
98
|
+
readonly body: string;
|
|
99
|
+
readonly authored: string;
|
|
100
|
+
invalid: boolean;
|
|
101
|
+
number?: number;
|
|
102
|
+
readonly references: string[];
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
type Footnotes = {
|
|
106
|
+
readonly byLabel: Map<string, Footnote | null>;
|
|
107
|
+
readonly numbered: Footnote[];
|
|
108
|
+
definitions: number;
|
|
109
|
+
references: number;
|
|
110
|
+
disabled: boolean;
|
|
111
|
+
/** Plain display projections omit admitted citation navigation only. */
|
|
112
|
+
readonly plain?: true;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
function footnoteLabel(raw: string): string | undefined {
|
|
116
|
+
const label = raw.normalize("NFC");
|
|
117
|
+
if (label.length > FOOTNOTE_LIMITS.labelBytes || !/^[\p{L}\p{M}\p{N}._:-]+$/u.test(label)) return undefined;
|
|
118
|
+
return new TextEncoder().encode(label).byteLength <= FOOTNOTE_LIMITS.labelBytes ? label : undefined;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function footnoteNumber(note: Footnote, footnotes: Footnotes): number {
|
|
122
|
+
if (note.number === undefined) {
|
|
123
|
+
note.number = footnotes.numbered.length + 1;
|
|
124
|
+
footnotes.numbered.push(note);
|
|
125
|
+
}
|
|
126
|
+
return note.number;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderFootnoteReference(raw: string, label: string, footnotes: Footnotes): string {
|
|
130
|
+
const key = footnoteLabel(label);
|
|
131
|
+
const note = key === undefined ? undefined : footnotes.byLabel.get(key);
|
|
132
|
+
if (footnotes.disabled || note === undefined || note === null || note.invalid || footnotes.references >= FOOTNOTE_LIMITS.references) {
|
|
133
|
+
return `<span class="unresolved footnote-reference" aria-label="Unresolved footnote">${escapeHtml(raw)}</span>`;
|
|
134
|
+
}
|
|
135
|
+
const number = footnoteNumber(note, footnotes);
|
|
136
|
+
// Colons cannot occur in heading slugs, so authored headings cannot capture
|
|
137
|
+
// these targets. Raw labels never enter an HTML id or fragment.
|
|
138
|
+
const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
|
|
139
|
+
note.references.push(id);
|
|
140
|
+
footnotes.references += 1;
|
|
141
|
+
if (footnotes.plain) return "";
|
|
142
|
+
return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderInline(text: string, ctx: PublishRenderContext, footnotes?: Footnotes): string {
|
|
84
146
|
let output = "";
|
|
85
147
|
let cursor = 0;
|
|
86
148
|
const length = text.length;
|
|
@@ -99,7 +161,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
|
99
161
|
if (closing !== -1) {
|
|
100
162
|
const code = rest.slice(ticks.length, closing).replace(/\s+/gu, " ");
|
|
101
163
|
output += `<code>${escapeHtml(code)}</code>`;
|
|
102
|
-
cursor +=
|
|
164
|
+
cursor += closing + ticks.length;
|
|
103
165
|
continue;
|
|
104
166
|
}
|
|
105
167
|
pushText("`");
|
|
@@ -198,6 +260,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
|
198
260
|
if (closing !== -1 && rest[closing + 1] === "(") {
|
|
199
261
|
const destination = readDestination(rest, closing + 2);
|
|
200
262
|
if (destination !== null) {
|
|
263
|
+
// Supported explicit links take precedence over footnote markers.
|
|
201
264
|
const label = renderInline(rest.slice(1, closing), ctx);
|
|
202
265
|
const target = destination.target;
|
|
203
266
|
const asset = ctx.resolveAsset(target, ctx.source);
|
|
@@ -219,6 +282,14 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
|
219
282
|
continue;
|
|
220
283
|
}
|
|
221
284
|
}
|
|
285
|
+
if (footnotes !== undefined && rest.startsWith("[^")) {
|
|
286
|
+
const end = rest.indexOf("]", 2);
|
|
287
|
+
if (end !== -1) {
|
|
288
|
+
output += renderFootnoteReference(rest.slice(0, end + 1), rest.slice(2, end), footnotes);
|
|
289
|
+
cursor += end + 1;
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
222
293
|
pushText("[");
|
|
223
294
|
cursor += 1;
|
|
224
295
|
continue;
|
|
@@ -249,7 +320,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
|
249
320
|
if (emphasis !== undefined && emphasis !== "_") {
|
|
250
321
|
const closing = rest.indexOf(emphasis, emphasis.length);
|
|
251
322
|
if (closing > emphasis.length) {
|
|
252
|
-
const inner = renderInline(rest.slice(emphasis.length, closing), ctx);
|
|
323
|
+
const inner = renderInline(rest.slice(emphasis.length, closing), ctx, footnotes);
|
|
253
324
|
const tag = emphasis === "**" || emphasis === "__"
|
|
254
325
|
? "strong"
|
|
255
326
|
: emphasis === "~~"
|
|
@@ -265,7 +336,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
|
|
|
265
336
|
if (emphasis === "_") {
|
|
266
337
|
const closing = rest.indexOf("_", 1);
|
|
267
338
|
if (closing > 1) {
|
|
268
|
-
output += `<em>${renderInline(rest.slice(1, closing), ctx)}</em>`;
|
|
339
|
+
output += `<em>${renderInline(rest.slice(1, closing), ctx, footnotes)}</em>`;
|
|
269
340
|
cursor += closing + 1;
|
|
270
341
|
continue;
|
|
271
342
|
}
|
|
@@ -398,49 +469,145 @@ function tableCells(line: string): string[] {
|
|
|
398
469
|
return cells.map((cell) => cell.trim());
|
|
399
470
|
}
|
|
400
471
|
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
472
|
+
type ListPart =
|
|
473
|
+
| { readonly type: "open" | "close"; readonly ordered: boolean }
|
|
474
|
+
| { readonly type: "item"; readonly text: string; readonly checked?: boolean }
|
|
475
|
+
| { readonly type: "continuation"; readonly text: string };
|
|
476
|
+
|
|
477
|
+
type MarkdownBlock =
|
|
478
|
+
| { readonly type: "paragraph"; readonly text: string }
|
|
479
|
+
| { readonly type: "heading"; readonly level: number; readonly text: string }
|
|
480
|
+
| { readonly type: "code"; readonly language: string; readonly text: string }
|
|
481
|
+
| { readonly type: "break" }
|
|
482
|
+
| { readonly type: "quote"; readonly blocks: readonly MarkdownBlock[] }
|
|
483
|
+
| { readonly type: "table"; readonly header: readonly string[]; readonly rows: readonly string[][] }
|
|
484
|
+
| { readonly type: "list"; readonly ordered: boolean; readonly parts: readonly ListPart[] }
|
|
485
|
+
| { readonly type: "literal"; readonly text: string }
|
|
486
|
+
| { readonly type: "footnote"; readonly note: Footnote };
|
|
487
|
+
|
|
488
|
+
type Comments = { closing?: "-->" | "%%"; code?: string };
|
|
489
|
+
|
|
490
|
+
/** Strip comments only in prose; code blocks never pass through this scanner. */
|
|
491
|
+
function withoutComments(line: string, comments: Comments, continuesCode?: (ticks: string) => boolean): string {
|
|
492
|
+
let text = "";
|
|
493
|
+
let cursor = 0;
|
|
494
|
+
while (cursor < line.length) {
|
|
495
|
+
if (comments.code !== undefined) {
|
|
496
|
+
const end = line.indexOf(comments.code, cursor);
|
|
497
|
+
if (end === -1) return text + line.slice(cursor);
|
|
498
|
+
const next = end + comments.code.length;
|
|
499
|
+
text += line.slice(cursor, next);
|
|
500
|
+
cursor = next;
|
|
501
|
+
delete comments.code;
|
|
502
|
+
continue;
|
|
503
|
+
}
|
|
504
|
+
if (comments.closing !== undefined) {
|
|
505
|
+
const end = line.indexOf(comments.closing, cursor);
|
|
506
|
+
if (end === -1) return text;
|
|
507
|
+
cursor = end + comments.closing.length;
|
|
508
|
+
delete comments.closing;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const rest = line.slice(cursor);
|
|
512
|
+
if (rest[0] === "\\" && rest.length > 1) {
|
|
513
|
+
text += rest.slice(0, 2);
|
|
514
|
+
cursor += 2;
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
if (rest[0] === "`") {
|
|
518
|
+
const ticks = /^`+/u.exec(rest)?.[0] ?? "`";
|
|
519
|
+
const closing = rest.indexOf(ticks, ticks.length);
|
|
520
|
+
if (closing !== -1) {
|
|
521
|
+
const end = closing + ticks.length;
|
|
522
|
+
text += rest.slice(0, end);
|
|
523
|
+
cursor += end;
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (continuesCode?.(ticks) === true) {
|
|
527
|
+
comments.code = ticks;
|
|
528
|
+
return text + rest;
|
|
411
529
|
}
|
|
412
530
|
}
|
|
531
|
+
if (rest.startsWith("<!--") || rest.startsWith("%%")) {
|
|
532
|
+
comments.closing = rest.startsWith("<!--") ? "-->" : "%%";
|
|
533
|
+
cursor += comments.closing === "-->" ? 4 : 2;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
text += rest[0] ?? "";
|
|
537
|
+
cursor += 1;
|
|
413
538
|
}
|
|
539
|
+
return text;
|
|
540
|
+
}
|
|
414
541
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
542
|
+
function paragraphBoundary(line: string, next: string): boolean {
|
|
543
|
+
return line.trim() === "" || /^(?: {4}|\t|#{1,6}\s|\s{0,3}>|\s{0,3}(?:`{3,}|~{3,}))/u.test(line)
|
|
544
|
+
|| listMarker(line) !== undefined || (line.includes("|") && isTableDivider(next))
|
|
545
|
+
|| /^\s{0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/u.test(line);
|
|
546
|
+
}
|
|
419
547
|
|
|
548
|
+
function hasContinuedInlineCode(lines: readonly string[], start: number, ticks: string): boolean {
|
|
549
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
550
|
+
const line = lines[index] ?? "";
|
|
551
|
+
if (paragraphBoundary(line, lines[index + 1] ?? "")) return false;
|
|
552
|
+
if (line.includes(ticks)) return true;
|
|
553
|
+
}
|
|
554
|
+
return false;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function admitFootnote(rawLabel: string, body: readonly string[], authored: readonly string[], footnotes: Footnotes): Footnote {
|
|
558
|
+
const text = body.join("\n");
|
|
559
|
+
const label = footnoteLabel(rawLabel);
|
|
560
|
+
const note: Footnote = {
|
|
561
|
+
body: text,
|
|
562
|
+
authored: authored.join("\n"),
|
|
563
|
+
invalid: label === undefined || body.length > FOOTNOTE_LIMITS.bodyLines || text.length > FOOTNOTE_LIMITS.bodyBytes
|
|
564
|
+
|| new TextEncoder().encode(text).byteLength > FOOTNOTE_LIMITS.bodyBytes || text.trim() === "",
|
|
565
|
+
references: [],
|
|
566
|
+
};
|
|
567
|
+
footnotes.definitions += 1;
|
|
568
|
+
if (footnotes.definitions > FOOTNOTE_LIMITS.definitions) {
|
|
569
|
+
footnotes.disabled = true;
|
|
570
|
+
footnotes.byLabel.clear();
|
|
571
|
+
}
|
|
572
|
+
if (!footnotes.disabled && label !== undefined) {
|
|
573
|
+
if (footnotes.byLabel.has(label)) {
|
|
574
|
+
const prior = footnotes.byLabel.get(label);
|
|
575
|
+
if (prior !== undefined && prior !== null) prior.invalid = true;
|
|
576
|
+
note.invalid = true;
|
|
577
|
+
footnotes.byLabel.set(label, null);
|
|
578
|
+
} else {
|
|
579
|
+
footnotes.byLabel.set(label, note.invalid ? null : note);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return note;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/** One block scan owns both rendering precedence and definition admission. */
|
|
586
|
+
function parseMarkdownBlocks(content: string, footnotes: Footnotes, topLevel: boolean): MarkdownBlock[] {
|
|
587
|
+
const lines = content.split("\n");
|
|
588
|
+
let index = 0;
|
|
589
|
+
if (topLevel && lines[0]?.trim() === "---") {
|
|
590
|
+
for (let cursor = 1; cursor < lines.length; cursor += 1) {
|
|
591
|
+
if (lines[cursor]?.trim() === "---" || lines[cursor]?.trim() === "...") {
|
|
592
|
+
index = cursor + 1;
|
|
593
|
+
break;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
const blocks: MarkdownBlock[] = [];
|
|
598
|
+
const comments: Comments = {};
|
|
420
599
|
const paragraph: string[] = [];
|
|
421
600
|
const flushParagraph = (): void => {
|
|
422
601
|
if (paragraph.length === 0) return;
|
|
423
|
-
|
|
602
|
+
blocks.push({ type: "paragraph", text: paragraph.join(" ") });
|
|
424
603
|
paragraph.length = 0;
|
|
425
604
|
};
|
|
426
605
|
|
|
427
606
|
while (index < lines.length) {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
const close = line.indexOf("-->");
|
|
433
|
-
index += 1;
|
|
434
|
-
if (close !== -1) inComment = false;
|
|
435
|
-
continue;
|
|
436
|
-
}
|
|
437
|
-
if (line.trim().startsWith("%%") && line.trim().endsWith("%%") && line.trim().length > 3) {
|
|
438
|
-
index += 1;
|
|
439
|
-
continue;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
// Fenced code.
|
|
443
|
-
const fence = /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(line);
|
|
607
|
+
const raw = lines[index] ?? "";
|
|
608
|
+
// Code has precedence over prose comments and footnote definitions.
|
|
609
|
+
const inCode = comments.code !== undefined;
|
|
610
|
+
const fence = comments.closing === undefined && !inCode ? /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(raw) : null;
|
|
444
611
|
if (fence !== null) {
|
|
445
612
|
flushParagraph();
|
|
446
613
|
const marker = fence[1] ?? "```";
|
|
@@ -450,151 +617,262 @@ export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext)
|
|
|
450
617
|
while (index < lines.length) {
|
|
451
618
|
const candidate = lines[index] ?? "";
|
|
452
619
|
const close = /^\s{0,3}(`{3,}|~{3,})\s*$/u.exec(candidate);
|
|
453
|
-
if (close !== null && (close[1] ?? "")[0] === marker[0] && (close[1] ?? "").length >= marker.length)
|
|
454
|
-
break;
|
|
455
|
-
}
|
|
620
|
+
if (close !== null && (close[1] ?? "")[0] === marker[0] && (close[1] ?? "").length >= marker.length) break;
|
|
456
621
|
body.push(candidate);
|
|
457
622
|
index += 1;
|
|
458
623
|
}
|
|
459
624
|
index += 1;
|
|
460
|
-
|
|
461
|
-
|
|
625
|
+
blocks.push({ type: "code", language, text: body.join("\n") });
|
|
626
|
+
continue;
|
|
627
|
+
}
|
|
628
|
+
if (comments.closing === undefined && !inCode && /^(?: {4}|\t)/u.test(raw)) {
|
|
629
|
+
flushParagraph();
|
|
630
|
+
const body: string[] = [];
|
|
631
|
+
while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
|
|
632
|
+
body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
|
|
633
|
+
index += 1;
|
|
634
|
+
}
|
|
635
|
+
blocks.push({ type: "code", language: "", text: body.join("\n").replace(/\n+$/u, "") });
|
|
636
|
+
continue;
|
|
637
|
+
}
|
|
638
|
+
const rawDefinition = comments.closing === undefined && !inCode ? /^ {0,3}\[\^([^\]\n]*)\]:/u.exec(raw) : null;
|
|
639
|
+
if (rawDefinition !== null) {
|
|
640
|
+
flushParagraph();
|
|
641
|
+
const authored = [raw];
|
|
642
|
+
index += 1;
|
|
643
|
+
while (index < lines.length) {
|
|
644
|
+
const next = lines[index] ?? "";
|
|
645
|
+
if (/^(?: {4}|\t)/u.test(next) || (next.trim() === "" && /^(?: {4}|\t)/u.test(lines[index + 1] ?? ""))) {
|
|
646
|
+
authored.push(next);
|
|
647
|
+
index += 1;
|
|
648
|
+
} else {
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Scan the full inline body together so comments inside a code span
|
|
653
|
+
// crossing continuation lines remain code, rather than disappearing.
|
|
654
|
+
const clean = withoutComments(authored.join("\n"), comments).split("\n");
|
|
655
|
+
const definition = /^ {0,3}\[\^([^\]\n]*)\]:[ \t]*(.*)$/u.exec(clean[0] ?? "");
|
|
656
|
+
if (!topLevel || definition === null) {
|
|
657
|
+
blocks.push({ type: "literal", text: clean.join("\n") });
|
|
658
|
+
} else {
|
|
659
|
+
const body = [definition[2] ?? "", ...clean.slice(1).map((line) => line.replace(/^(?: {4}|\t)/u, ""))];
|
|
660
|
+
blocks.push({ type: "footnote", note: admitFootnote(rawDefinition[1] ?? "", body, clean, footnotes) });
|
|
661
|
+
}
|
|
662
|
+
continue;
|
|
663
|
+
}
|
|
664
|
+
// Quoted blocks own their comment/code scope after removing the marker.
|
|
665
|
+
const line = comments.closing === undefined && !inCode && /^\s{0,3}>/u.test(raw)
|
|
666
|
+
? raw
|
|
667
|
+
: withoutComments(raw, comments, paragraphBoundary(raw, lines[index + 1] ?? "")
|
|
668
|
+
? undefined
|
|
669
|
+
: (ticks) => hasContinuedInlineCode(lines, index + 1, ticks));
|
|
670
|
+
if (inCode) {
|
|
671
|
+
paragraph.push(line);
|
|
672
|
+
index += 1;
|
|
462
673
|
continue;
|
|
463
674
|
}
|
|
464
|
-
|
|
465
|
-
// Blank line.
|
|
466
675
|
if (line.trim() === "") {
|
|
467
676
|
flushParagraph();
|
|
468
677
|
index += 1;
|
|
469
678
|
continue;
|
|
470
679
|
}
|
|
471
|
-
|
|
472
|
-
// ATX heading.
|
|
473
680
|
const heading = /^(#{1,6})\s+(.*)$/u.exec(line);
|
|
474
681
|
if (heading !== null) {
|
|
475
682
|
flushParagraph();
|
|
476
|
-
|
|
477
|
-
const body = renderInline((heading[2] ?? "").replace(/\s+#+$/u, ""), ctx);
|
|
478
|
-
const id = anchorId(stripMarkup(body), anchors);
|
|
479
|
-
html.push(`<h${level} id="${escapeAttribute(id)}">${body}</h${level}>`);
|
|
683
|
+
blocks.push({ type: "heading", level: (heading[1] ?? "#").length, text: (heading[2] ?? "").replace(/\s+#+$/u, "") });
|
|
480
684
|
index += 1;
|
|
481
685
|
continue;
|
|
482
686
|
}
|
|
483
|
-
|
|
484
|
-
// Thematic break.
|
|
485
687
|
if (/^\s{0,3}(?:\*\s*){3,}$/u.test(line) || /^\s{0,3}(?:-\s*){3,}$/u.test(line) || /^\s{0,3}(?:_\s*){3,}$/u.test(line)) {
|
|
486
688
|
flushParagraph();
|
|
487
|
-
|
|
689
|
+
blocks.push({ type: "break" });
|
|
488
690
|
index += 1;
|
|
489
691
|
continue;
|
|
490
692
|
}
|
|
491
|
-
|
|
492
|
-
// Blockquote (one level per pass; nesting via > > accumulation).
|
|
493
693
|
if (/^\s{0,3}>/u.test(line)) {
|
|
494
694
|
flushParagraph();
|
|
495
|
-
const quote
|
|
695
|
+
const quote = [line.replace(/^\s{0,3}>\s?/u, "")];
|
|
696
|
+
index += 1;
|
|
496
697
|
while (index < lines.length && /^\s{0,3}>/u.test(lines[index] ?? "")) {
|
|
497
698
|
quote.push((lines[index] ?? "").replace(/^\s{0,3}>\s?/u, ""));
|
|
498
699
|
index += 1;
|
|
499
700
|
}
|
|
500
|
-
|
|
701
|
+
blocks.push({ type: "quote", blocks: parseMarkdownBlocks(quote.join("\n"), footnotes, false) });
|
|
501
702
|
continue;
|
|
502
703
|
}
|
|
503
|
-
|
|
504
|
-
// Tables: header row, divider, body rows.
|
|
505
704
|
if (line.includes("|") && index + 1 < lines.length && isTableDivider(lines[index + 1] ?? "")) {
|
|
506
705
|
flushParagraph();
|
|
507
706
|
const header = tableCells(line);
|
|
707
|
+
const rows: string[][] = [];
|
|
508
708
|
index += 2;
|
|
509
|
-
const rows: string[] = [];
|
|
510
709
|
while (index < lines.length && (lines[index] ?? "").includes("|") && (lines[index] ?? "").trim() !== "") {
|
|
511
|
-
|
|
710
|
+
const row = withoutComments(lines[index] ?? "", comments);
|
|
711
|
+
if (row.trim() !== "") rows.push(tableCells(row));
|
|
512
712
|
index += 1;
|
|
513
713
|
}
|
|
514
|
-
|
|
515
|
-
const bodyRows = rows.map((row) =>
|
|
516
|
-
`<tr>${tableCells(row).map((cell) => `<td>${renderInline(cell, ctx)}</td>`).join("")}</tr>`).join("");
|
|
517
|
-
html.push(`<table><thead><tr>${head}</tr></thead><tbody>${bodyRows}</tbody></table>`);
|
|
714
|
+
blocks.push({ type: "table", header, rows });
|
|
518
715
|
continue;
|
|
519
716
|
}
|
|
520
|
-
|
|
521
|
-
// Lists: flat scan with indent-based nesting.
|
|
522
717
|
const marker = listMarker(line);
|
|
523
718
|
if (marker !== undefined) {
|
|
524
719
|
flushParagraph();
|
|
525
|
-
const
|
|
526
|
-
let ordered = marker.ordered;
|
|
720
|
+
const parts: ListPart[] = [];
|
|
527
721
|
const baseIndent = marker.indent;
|
|
528
722
|
const stack: { readonly indent: number; readonly ordered: boolean }[] = [];
|
|
723
|
+
let currentLine = line;
|
|
529
724
|
while (index < lines.length) {
|
|
530
|
-
const current = listMarker(
|
|
531
|
-
const raw = lines[index] ?? "";
|
|
725
|
+
const current = listMarker(currentLine);
|
|
532
726
|
if (current === undefined) {
|
|
533
|
-
if (
|
|
727
|
+
if (currentLine.trim() === "") {
|
|
534
728
|
index += 1;
|
|
535
729
|
break;
|
|
536
730
|
}
|
|
537
|
-
const continuationIndent =
|
|
538
|
-
if (
|
|
539
|
-
|
|
731
|
+
const continuationIndent = currentLine.length - currentLine.trimStart().length;
|
|
732
|
+
if (parts.length > 0 && continuationIndent > baseIndent) {
|
|
733
|
+
parts.push({ type: "continuation", text: currentLine.trim() });
|
|
540
734
|
index += 1;
|
|
541
|
-
|
|
735
|
+
} else {
|
|
736
|
+
break;
|
|
542
737
|
}
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
|
|
558
|
-
const popped = stack.pop();
|
|
559
|
-
items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
|
|
738
|
+
} else {
|
|
739
|
+
const body = currentLine.slice(currentLine.search(/\S/u));
|
|
740
|
+
const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
|
|
741
|
+
const task = /^\[([ xX])\]\s+/u.exec(itemBody);
|
|
742
|
+
const text = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
|
|
743
|
+
if (current.indent > (stack[stack.length - 1]?.indent ?? baseIndent) && stack.length < 8) {
|
|
744
|
+
stack.push({ indent: current.indent, ordered: current.ordered });
|
|
745
|
+
parts.push({ type: "open", ordered: current.ordered });
|
|
746
|
+
}
|
|
747
|
+
while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
|
|
748
|
+
parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
|
|
749
|
+
}
|
|
750
|
+
parts.push({ type: "item", text, ...(task === null ? {} : { checked: (task[1] ?? " ").toLowerCase() === "x" }) });
|
|
751
|
+
index += 1;
|
|
560
752
|
}
|
|
561
|
-
|
|
562
|
-
index
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
const popped = stack.pop();
|
|
566
|
-
items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
|
|
753
|
+
// Do not consume comments on a line that belongs to the next block.
|
|
754
|
+
const next = lines[index] ?? "";
|
|
755
|
+
if (listMarker(next) === undefined && next.trim() !== "" && next.length - next.trimStart().length <= baseIndent) break;
|
|
756
|
+
currentLine = withoutComments(next, comments);
|
|
567
757
|
}
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
// HTML comment open.
|
|
573
|
-
const commentStart = line.indexOf("<!--");
|
|
574
|
-
if (commentStart !== -1) {
|
|
575
|
-
const close = line.indexOf("-->", commentStart + 4);
|
|
576
|
-
if (close === -1) inComment = true;
|
|
577
|
-
index += 1;
|
|
758
|
+
while (stack.length > 0) parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
|
|
759
|
+
blocks.push({ type: "list", ordered: marker.ordered, parts });
|
|
578
760
|
continue;
|
|
579
761
|
}
|
|
762
|
+
paragraph.push(line);
|
|
763
|
+
index += 1;
|
|
764
|
+
}
|
|
765
|
+
flushParagraph();
|
|
766
|
+
return blocks;
|
|
767
|
+
}
|
|
580
768
|
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
769
|
+
function renderBlocks(blocks: readonly MarkdownBlock[], ctx: PublishRenderContext, footnotes: Footnotes, anchors: Map<string, number>): string {
|
|
770
|
+
return blocks.map((block): string => {
|
|
771
|
+
switch (block.type) {
|
|
772
|
+
case "paragraph": return `<p>${renderInline(block.text, ctx, footnotes)}</p>`;
|
|
773
|
+
case "literal": return `<p>${escapeHtml(block.text).replaceAll("\n", "<br>")}</p>`;
|
|
774
|
+
case "heading": {
|
|
775
|
+
const body = renderInline(block.text, ctx, footnotes);
|
|
776
|
+
return `<h${block.level} id="${escapeAttribute(anchorId(stripMarkup(body), anchors))}">${body}</h${block.level}>`;
|
|
588
777
|
}
|
|
589
|
-
|
|
590
|
-
|
|
778
|
+
case "code": return `<pre><code${block.language === "" ? "" : ` class="language-${escapeAttribute(block.language)}"`}>${escapeHtml(block.text)}</code></pre>`;
|
|
779
|
+
case "break": return "<hr>";
|
|
780
|
+
case "quote": return `<blockquote>${renderBlocks(block.blocks, ctx, footnotes, anchors)}</blockquote>`;
|
|
781
|
+
case "table": {
|
|
782
|
+
const header = block.header.map((cell) => `<th>${renderInline(cell, ctx, footnotes)}</th>`).join("");
|
|
783
|
+
const rows = block.rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, ctx, footnotes)}</td>`).join("")}</tr>`).join("");
|
|
784
|
+
return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
|
|
785
|
+
}
|
|
786
|
+
case "list": {
|
|
787
|
+
const parts = block.parts.map((part) => {
|
|
788
|
+
switch (part.type) {
|
|
789
|
+
case "open": return `<${part.ordered ? "ol" : "ul"}>`;
|
|
790
|
+
case "close": return `</${part.ordered ? "ol" : "ul"}>`;
|
|
791
|
+
case "continuation": return `\n${renderInline(part.text, ctx, footnotes)}`;
|
|
792
|
+
case "item": return `<li>${part.checked === undefined ? "" : `<input type="checkbox" disabled${part.checked ? " checked" : ""}> `}${renderInline(part.text, ctx, footnotes)}</li>`;
|
|
793
|
+
}
|
|
794
|
+
}).join("");
|
|
795
|
+
return `<${block.ordered ? "ol" : "ul"}>${parts}</${block.ordered ? "ol" : "ul"}>`;
|
|
796
|
+
}
|
|
797
|
+
case "footnote": return footnotes.disabled || block.note.invalid
|
|
798
|
+
? `<p class="unresolved footnote-definition">${escapeHtml(block.note.authored).replaceAll("\n", "<br>")}</p>`
|
|
799
|
+
: "";
|
|
591
800
|
}
|
|
801
|
+
}).filter((html) => html !== "").join("\n");
|
|
802
|
+
}
|
|
592
803
|
|
|
593
|
-
|
|
594
|
-
|
|
804
|
+
/** Render authored Markdown to a sanitized HTML fragment. */
|
|
805
|
+
export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext): string {
|
|
806
|
+
const footnotes: Footnotes = { byLabel: new Map(), numbered: [], definitions: 0, references: 0, disabled: false };
|
|
807
|
+
const blocks = parseMarkdownBlocks(content, footnotes, true);
|
|
808
|
+
const body = renderBlocks(blocks, ctx, footnotes, new Map());
|
|
809
|
+
if (footnotes.disabled) return body;
|
|
810
|
+
// Keep unreferenced authored definitions visible after referenced notes.
|
|
811
|
+
for (const note of footnotes.byLabel.values()) {
|
|
812
|
+
if (note !== null && !note.invalid) footnoteNumber(note, footnotes);
|
|
595
813
|
}
|
|
596
|
-
|
|
597
|
-
|
|
814
|
+
if (footnotes.numbered.length === 0) return body;
|
|
815
|
+
const notes = footnotes.numbered.map((note) => {
|
|
816
|
+
const number = note.number;
|
|
817
|
+
const backlinks = note.references.map((id, index) => `<a href="#${id}" role="doc-backlink" aria-label="Back to reference ${index + 1} for footnote ${number}">↩ ${index + 1}</a>`).join(" ");
|
|
818
|
+
// Definitions are inline prose. Footnote syntax inside them stays literal,
|
|
819
|
+
// so cycles cannot expand and every backlink corresponds to authored prose.
|
|
820
|
+
const text = renderInline(note.body.replaceAll("\n", " "), ctx);
|
|
821
|
+
return `<li id="wordcell:footnote:${number}" tabindex="-1">${text}${backlinks === "" ? "" : ` ${backlinks}`}</li>`;
|
|
822
|
+
}).join("\n");
|
|
823
|
+
return `${body}${body === "" ? "" : "\n"}<section class="footnotes" aria-label="Footnotes"><ol>\n${notes}\n</ol></section>`;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
export type PublishMarkdownText = {
|
|
827
|
+
readonly text: string;
|
|
828
|
+
readonly preview: string;
|
|
829
|
+
};
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Readable display text, derived before truncation or case normalization.
|
|
833
|
+
* Shares the article grammar and footnote admission rules; it is not an index
|
|
834
|
+
* or a replacement for authored Markdown. Valid citation navigation disappears,
|
|
835
|
+
* while unresolved definitions/references, escaped text, and code stay literal.
|
|
836
|
+
*/
|
|
837
|
+
export function projectMarkdownText(content: string, description?: string): PublishMarkdownText {
|
|
838
|
+
const footnotes: Footnotes = { byLabel: new Map(), numbered: [], definitions: 0, references: 0, disabled: false, plain: true };
|
|
839
|
+
const blocks = parseMarkdownBlocks(content, footnotes, true);
|
|
840
|
+
// Display labels need no filesystem, URL fetch, or selected-note resolution.
|
|
841
|
+
// Unresolved local assets retain their authored alt text through renderInline.
|
|
842
|
+
const context: PublishRenderContext = {
|
|
843
|
+
source: "", resolveNote: () => undefined, resolveAsset: () => undefined, noteHref: () => "",
|
|
844
|
+
};
|
|
845
|
+
const inline = (text: string, state?: Footnotes): string => stripMarkup(renderInline(text, context, state));
|
|
846
|
+
const parts = (items: readonly MarkdownBlock[], state: Footnotes): string[] => items.map((block): string => {
|
|
847
|
+
switch (block.type) {
|
|
848
|
+
case "paragraph": case "heading": return inline(block.text, state);
|
|
849
|
+
case "literal": case "code": return block.text;
|
|
850
|
+
case "break": return "";
|
|
851
|
+
case "quote": return parts(block.blocks, state).join("\n\n");
|
|
852
|
+
case "table": return [block.header, ...block.rows].map((row) => row.map((cell) => inline(cell, state)).join(" ")).join("\n");
|
|
853
|
+
case "list": return block.parts.map((part) => part.type === "item" || part.type === "continuation" ? inline(part.text, state) : "").filter(Boolean).join("\n");
|
|
854
|
+
case "footnote": return state.disabled || block.note.invalid ? block.note.authored : "";
|
|
855
|
+
}
|
|
856
|
+
});
|
|
857
|
+
const rendered = parts(blocks, footnotes);
|
|
858
|
+
// The article orders valid definitions by first reference, then unreferenced
|
|
859
|
+
// definitions. Nested references in their bodies deliberately remain literal.
|
|
860
|
+
if (!footnotes.disabled) for (const note of footnotes.byLabel.values()) {
|
|
861
|
+
if (note !== null && !note.invalid) footnoteNumber(note, footnotes);
|
|
862
|
+
}
|
|
863
|
+
const text = [...rendered, ...footnotes.numbered.map((note) => inline(note.body.replaceAll("\n", " ")))].filter(Boolean).join("\n\n");
|
|
864
|
+
let preview = rendered.find((value, index) => blocks[index]?.type === "paragraph" && value.trim() !== "") ?? text;
|
|
865
|
+
if (description !== undefined && description.trim() !== "") {
|
|
866
|
+
// Resolve description citations against the same authored definitions, but
|
|
867
|
+
// never let preview rendering consume the article's citation allowance.
|
|
868
|
+
const state: Footnotes = {
|
|
869
|
+
byLabel: new Map([...footnotes.byLabel].map(([label, note]) => [label, note === null ? null : { body: note.body, authored: note.authored, invalid: note.invalid, references: [] }])),
|
|
870
|
+
numbered: [], definitions: footnotes.definitions, references: 0, disabled: footnotes.disabled, plain: true,
|
|
871
|
+
};
|
|
872
|
+
const authored = parts(parseMarkdownBlocks(description, state, false), state).filter(Boolean).join("\n\n");
|
|
873
|
+
if (authored.trim() !== "") preview = authored;
|
|
874
|
+
}
|
|
875
|
+
return { text, preview };
|
|
598
876
|
}
|
|
599
877
|
|
|
600
878
|
export function stripMarkup(html: string): string {
|