@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.
@@ -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
- const seen = used.get(base) ?? 0;
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
- return seen === 0 ? base : `${base}-${seen + 1}`;
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
- function renderInline(text: string, ctx: PublishRenderContext): string {
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 += ticks.length + closing + ticks.length;
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
- /** Render authored Markdown to a sanitized HTML fragment. */
402
- export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext): string {
403
- const lines = content.split("\n");
404
- // Strip frontmatter.
405
- let start = 0;
406
- if (lines[0]?.trim() === "---") {
407
- for (let index = 1; index < lines.length; index += 1) {
408
- if (lines[index]?.trim() === "---" || lines[index]?.trim() === "...") {
409
- start = index + 1;
410
- break;
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
- const anchors = new Map<string, number>();
416
- const html: string[] = [];
417
- let index = start;
418
- let inComment = false;
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
- html.push(`<p>${renderInline(paragraph.join("\n").replace(/\n/gu, " "), ctx)}</p>`);
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 line = lines[index] ?? "";
429
-
430
- // HTML comments and Obsidian %% comments are stripped entirely.
431
- if (inComment) {
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
- const className = language === "" ? "" : ` class="language-${escapeAttribute(language)}"`;
461
- html.push(`<pre><code${className}>${escapeHtml(body.join("\n"))}</code></pre>`);
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
- const level = (heading[1] ?? "#").length;
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
- html.push("<hr>");
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: string[] = [];
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
- html.push(`<blockquote>${renderMarkdownToHtml(quote.join("\n"), ctx)}</blockquote>`);
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
- rows.push(lines[index] ?? "");
710
+ const row = withoutComments(lines[index] ?? "", comments);
711
+ if (row.trim() !== "") rows.push(tableCells(row));
512
712
  index += 1;
513
713
  }
514
- const head = header.map((cell) => `<th>${renderInline(cell, ctx)}</th>`).join("");
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 items: string[] = [];
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(lines[index] ?? "");
531
- const raw = lines[index] ?? "";
725
+ const current = listMarker(currentLine);
532
726
  if (current === undefined) {
533
- if (raw.trim() === "") {
727
+ if (currentLine.trim() === "") {
534
728
  index += 1;
535
729
  break;
536
730
  }
537
- const continuationIndent = raw.length - raw.trimStart().length;
538
- if (items.length > 0 && continuationIndent > baseIndent) {
539
- items.push(`\n${renderInline(raw.trim(), ctx)}`);
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
- continue;
735
+ } else {
736
+ break;
542
737
  }
543
- break;
544
- }
545
- const body = raw.slice(raw.search(/\S/u));
546
- const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
547
- const task = /^\[([ xX])\]\s+/u.exec(itemBody);
548
- const content = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
549
- const checkbox = task === null
550
- ? ""
551
- : `<input type="checkbox" disabled${(task[1] ?? " ").toLowerCase() === "x" ? " checked" : ""}> `;
552
- const top = stack[stack.length - 1];
553
- if (current.indent > (top?.indent ?? baseIndent) && stack.length < 8) {
554
- stack.push({ indent: current.indent, ordered: current.ordered });
555
- items.push(`<${current.ordered ? "ol" : "ul"}>`);
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
- items.push(`<li>${checkbox}${renderInline(content, ctx)}</li>`);
562
- index += 1;
563
- }
564
- while (stack.length > 0) {
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
- html.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
569
- continue;
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
- // Indented code.
582
- if (/^(?: {4}|\t)/u.test(line)) {
583
- flushParagraph();
584
- const body: string[] = [];
585
- while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
586
- body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
587
- index += 1;
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
- html.push(`<pre><code>${escapeHtml(body.join("\n").replace(/\n+$/u, ""))}</code></pre>`);
590
- continue;
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
- paragraph.push(line);
594
- index += 1;
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
- flushParagraph();
597
- return html.join("\n");
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 {