@hraness/wordcell 0.22.2 → 0.22.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/README.md CHANGED
@@ -42,14 +42,14 @@ Git evidence, and selective publishing. [Compare the tradeoffs](https://github.c
42
42
  The CLI and TypeScript SDK run with Bun. Install the versioned GitHub archive:
43
43
 
44
44
  ```sh
45
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz
45
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
46
46
  wordcell --help
47
47
  ```
48
48
 
49
49
  Prefer npm? The same release is [mirrored there](https://www.npmjs.com/package/@hraness/wordcell):
50
50
 
51
51
  ```sh
52
- npm install --global --ignore-scripts @hraness/wordcell@0.22.2
52
+ npm install --global --ignore-scripts @hraness/wordcell@0.22.3
53
53
  wordcell --help
54
54
  ```
55
55
 
@@ -91,7 +91,7 @@ After trying the CLI, install the public Agent Skill into a compatible agent,
91
91
  such as Claude Code, Codex, Cursor, or GitHub Copilot:
92
92
 
93
93
  ```sh
94
- bunx skills add hraness/wordcell#v0.22.2 --skill wordcell
94
+ bunx skills add hraness/wordcell#v0.22.3 --skill wordcell
95
95
  ```
96
96
 
97
97
  Then ask:
@@ -283,7 +283,7 @@ exist. [Graph queries and proof limits](https://github.com/hraness/wordcell/blob
283
283
  Add the same immutable release to a Bun project:
284
284
 
285
285
  ```sh
286
- bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz
286
+ bun add --exact --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
287
287
  ```
288
288
 
289
289
  The SDK provides read-only vault sessions, metadata queries, search, graph
package/dist/cli.js CHANGED
@@ -5,7 +5,7 @@ import {
5
5
  parseArguments,
6
6
  runExecutable,
7
7
  usage
8
- } from "./index-bw2r37z9.js";
8
+ } from "./index-yx6p1vzc.js";
9
9
  import"./index-bcknqxrq.js";
10
10
  import"./index-054mb7d3.js";
11
11
  import"./index-bqtqeak3.js";
@@ -41,7 +41,7 @@ import"./index-tf4wzjew.js";
41
41
  import"./index-1gwbassd.js";
42
42
  import"./index-vnybgywh.js";
43
43
  import"./index-1xxnjn0d.js";
44
- import"./index-hmw17zaa.js";
44
+ import"./index-a3j61wz9.js";
45
45
  import"./index-233z9wmn.js";
46
46
  import"./index-d13v9ckt.js";
47
47
  import"./index-48pz4jpc.js";
@@ -61,7 +61,7 @@ import {
61
61
 
62
62
  // src/cli.ts
63
63
  if (import.meta.main) {
64
- const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-p452p9c4.js");
64
+ const { standaloneSupportEnvironment, isUsefulSupportResult, runProductSupportCommand, showProductSupportInvitation } = await import("./support-vm8fw4yc.js");
65
65
  const env = standaloneSupportEnvironment();
66
66
  const args = process.argv.slice(2);
67
67
  if (args[0] === "support") {
@@ -187,9 +187,15 @@ function escapeAttribute(value) {
187
187
  }
188
188
  function anchorId(text, used) {
189
189
  const base = text.normalize("NFC").toLocaleLowerCase("en-US").replace(VOID_SLUG, "-").replace(/^[-._~]+|[-._~]+$/gu, "") || "section";
190
- const seen = used.get(base) ?? 0;
190
+ let seen = used.get(base) ?? 0;
191
+ let id = seen === 0 ? base : `${base}-${seen + 1}`;
192
+ while (used.has(id)) {
193
+ seen += 1;
194
+ id = `${base}-${seen + 1}`;
195
+ }
191
196
  used.set(base, seen + 1);
192
- return seen === 0 ? base : `${base}-${seen + 1}`;
197
+ used.set(id, 1);
198
+ return id;
193
199
  }
194
200
  function isExternalUrl(value) {
195
201
  return /^(?:https?|mailto):/iu.test(value);
@@ -221,7 +227,39 @@ function wikiDisplay(raw) {
221
227
  label: alias ?? (fragment === "" ? target : `${target} > ${fragment}`)
222
228
  };
223
229
  }
224
- function renderInline(text, ctx) {
230
+ var FOOTNOTE_LIMITS = {
231
+ labelBytes: 128,
232
+ definitions: 256,
233
+ bodyBytes: 8192,
234
+ bodyLines: 32,
235
+ references: 2048
236
+ };
237
+ function footnoteLabel(raw) {
238
+ const label = raw.normalize("NFC");
239
+ if (label.length > FOOTNOTE_LIMITS.labelBytes || !/^[\p{L}\p{M}\p{N}._:-]+$/u.test(label))
240
+ return;
241
+ return new TextEncoder().encode(label).byteLength <= FOOTNOTE_LIMITS.labelBytes ? label : undefined;
242
+ }
243
+ function footnoteNumber(note, footnotes) {
244
+ if (note.number === undefined) {
245
+ note.number = footnotes.numbered.length + 1;
246
+ footnotes.numbered.push(note);
247
+ }
248
+ return note.number;
249
+ }
250
+ function renderFootnoteReference(raw, label, footnotes) {
251
+ const key = footnoteLabel(label);
252
+ const note = key === undefined ? undefined : footnotes.byLabel.get(key);
253
+ if (footnotes.disabled || note === undefined || note === null || note.invalid || footnotes.references >= FOOTNOTE_LIMITS.references) {
254
+ return `<span class="unresolved footnote-reference" aria-label="Unresolved footnote">${escapeHtml(raw)}</span>`;
255
+ }
256
+ const number = footnoteNumber(note, footnotes);
257
+ const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
258
+ note.references.push(id);
259
+ footnotes.references += 1;
260
+ return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
261
+ }
262
+ function renderInline(text, ctx, footnotes) {
225
263
  let output = "";
226
264
  let cursor = 0;
227
265
  const length = text.length;
@@ -236,7 +274,7 @@ function renderInline(text, ctx) {
236
274
  if (closing !== -1) {
237
275
  const code = rest.slice(ticks.length, closing).replace(/\s+/gu, " ");
238
276
  output += `<code>${escapeHtml(code)}</code>`;
239
- cursor += ticks.length + closing + ticks.length;
277
+ cursor += closing + ticks.length;
240
278
  continue;
241
279
  }
242
280
  pushText("`");
@@ -348,6 +386,14 @@ function renderInline(text, ctx) {
348
386
  continue;
349
387
  }
350
388
  }
389
+ if (footnotes !== undefined && rest.startsWith("[^")) {
390
+ const end = rest.indexOf("]", 2);
391
+ if (end !== -1) {
392
+ output += renderFootnoteReference(rest.slice(0, end + 1), rest.slice(2, end), footnotes);
393
+ cursor += end + 1;
394
+ continue;
395
+ }
396
+ }
351
397
  pushText("[");
352
398
  cursor += 1;
353
399
  continue;
@@ -373,7 +419,7 @@ function renderInline(text, ctx) {
373
419
  if (emphasis !== undefined && emphasis !== "_") {
374
420
  const closing = rest.indexOf(emphasis, emphasis.length);
375
421
  if (closing > emphasis.length) {
376
- const inner = renderInline(rest.slice(emphasis.length, closing), ctx);
422
+ const inner = renderInline(rest.slice(emphasis.length, closing), ctx, footnotes);
377
423
  const tag = emphasis === "**" || emphasis === "__" ? "strong" : emphasis === "~~" ? "del" : emphasis === "==" ? "mark" : "em";
378
424
  output += `<${tag}>${inner}</${tag}>`;
379
425
  cursor += closing + emphasis.length;
@@ -383,7 +429,7 @@ function renderInline(text, ctx) {
383
429
  if (emphasis === "_") {
384
430
  const closing = rest.indexOf("_", 1);
385
431
  if (closing > 1) {
386
- output += `<em>${renderInline(rest.slice(1, closing), ctx)}</em>`;
432
+ output += `<em>${renderInline(rest.slice(1, closing), ctx, footnotes)}</em>`;
387
433
  cursor += closing + 1;
388
434
  continue;
389
435
  }
@@ -509,44 +555,125 @@ function tableCells(line) {
509
555
  cells.push(current);
510
556
  return cells.map((cell) => cell.trim());
511
557
  }
512
- function renderMarkdownToHtml(content, ctx) {
558
+ function withoutComments(line, comments, continuesCode) {
559
+ let text = "";
560
+ let cursor = 0;
561
+ while (cursor < line.length) {
562
+ if (comments.code !== undefined) {
563
+ const end = line.indexOf(comments.code, cursor);
564
+ if (end === -1)
565
+ return text + line.slice(cursor);
566
+ const next = end + comments.code.length;
567
+ text += line.slice(cursor, next);
568
+ cursor = next;
569
+ delete comments.code;
570
+ continue;
571
+ }
572
+ if (comments.closing !== undefined) {
573
+ const end = line.indexOf(comments.closing, cursor);
574
+ if (end === -1)
575
+ return text;
576
+ cursor = end + comments.closing.length;
577
+ delete comments.closing;
578
+ continue;
579
+ }
580
+ const rest = line.slice(cursor);
581
+ if (rest[0] === "\\" && rest.length > 1) {
582
+ text += rest.slice(0, 2);
583
+ cursor += 2;
584
+ continue;
585
+ }
586
+ if (rest[0] === "`") {
587
+ const ticks = /^`+/u.exec(rest)?.[0] ?? "`";
588
+ const closing = rest.indexOf(ticks, ticks.length);
589
+ if (closing !== -1) {
590
+ const end = closing + ticks.length;
591
+ text += rest.slice(0, end);
592
+ cursor += end;
593
+ continue;
594
+ }
595
+ if (continuesCode?.(ticks) === true) {
596
+ comments.code = ticks;
597
+ return text + rest;
598
+ }
599
+ }
600
+ if (rest.startsWith("<!--") || rest.startsWith("%%")) {
601
+ comments.closing = rest.startsWith("<!--") ? "-->" : "%%";
602
+ cursor += comments.closing === "-->" ? 4 : 2;
603
+ continue;
604
+ }
605
+ text += rest[0] ?? "";
606
+ cursor += 1;
607
+ }
608
+ return text;
609
+ }
610
+ function paragraphBoundary(line, next) {
611
+ return line.trim() === "" || /^(?: {4}|\t|#{1,6}\s|\s{0,3}>|\s{0,3}(?:`{3,}|~{3,}))/u.test(line) || listMarker(line) !== undefined || line.includes("|") && isTableDivider(next) || /^\s{0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/u.test(line);
612
+ }
613
+ function hasContinuedInlineCode(lines, start, ticks) {
614
+ for (let index = start;index < lines.length; index += 1) {
615
+ const line = lines[index] ?? "";
616
+ if (paragraphBoundary(line, lines[index + 1] ?? ""))
617
+ return false;
618
+ if (line.includes(ticks))
619
+ return true;
620
+ }
621
+ return false;
622
+ }
623
+ function admitFootnote(rawLabel, body, authored, footnotes) {
624
+ const text = body.join(`
625
+ `);
626
+ const label = footnoteLabel(rawLabel);
627
+ const note = {
628
+ body: text,
629
+ authored: authored.join(`
630
+ `),
631
+ invalid: label === undefined || body.length > FOOTNOTE_LIMITS.bodyLines || text.length > FOOTNOTE_LIMITS.bodyBytes || new TextEncoder().encode(text).byteLength > FOOTNOTE_LIMITS.bodyBytes || text.trim() === "",
632
+ references: []
633
+ };
634
+ footnotes.definitions += 1;
635
+ if (footnotes.definitions > FOOTNOTE_LIMITS.definitions) {
636
+ footnotes.disabled = true;
637
+ footnotes.byLabel.clear();
638
+ }
639
+ if (!footnotes.disabled && label !== undefined) {
640
+ if (footnotes.byLabel.has(label)) {
641
+ const prior = footnotes.byLabel.get(label);
642
+ if (prior !== undefined && prior !== null)
643
+ prior.invalid = true;
644
+ note.invalid = true;
645
+ footnotes.byLabel.set(label, null);
646
+ } else {
647
+ footnotes.byLabel.set(label, note.invalid ? null : note);
648
+ }
649
+ }
650
+ return note;
651
+ }
652
+ function parseMarkdownBlocks(content, footnotes, topLevel) {
513
653
  const lines = content.split(`
514
654
  `);
515
- let start = 0;
516
- if (lines[0]?.trim() === "---") {
517
- for (let index2 = 1;index2 < lines.length; index2 += 1) {
518
- if (lines[index2]?.trim() === "---" || lines[index2]?.trim() === "...") {
519
- start = index2 + 1;
655
+ let index = 0;
656
+ if (topLevel && lines[0]?.trim() === "---") {
657
+ for (let cursor = 1;cursor < lines.length; cursor += 1) {
658
+ if (lines[cursor]?.trim() === "---" || lines[cursor]?.trim() === "...") {
659
+ index = cursor + 1;
520
660
  break;
521
661
  }
522
662
  }
523
663
  }
524
- const anchors = new Map;
525
- const html = [];
526
- let index = start;
527
- let inComment = false;
664
+ const blocks = [];
665
+ const comments = {};
528
666
  const paragraph = [];
529
667
  const flushParagraph = () => {
530
668
  if (paragraph.length === 0)
531
669
  return;
532
- html.push(`<p>${renderInline(paragraph.join(`
533
- `).replace(/\n/gu, " "), ctx)}</p>`);
670
+ blocks.push({ type: "paragraph", text: paragraph.join(" ") });
534
671
  paragraph.length = 0;
535
672
  };
536
673
  while (index < lines.length) {
537
- const line = lines[index] ?? "";
538
- if (inComment) {
539
- const close = line.indexOf("-->");
540
- index += 1;
541
- if (close !== -1)
542
- inComment = false;
543
- continue;
544
- }
545
- if (line.trim().startsWith("%%") && line.trim().endsWith("%%") && line.trim().length > 3) {
546
- index += 1;
547
- continue;
548
- }
549
- const fence = /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(line);
674
+ const raw = lines[index] ?? "";
675
+ const inCode = comments.code !== undefined;
676
+ const fence = comments.closing === undefined && !inCode ? /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(raw) : null;
550
677
  if (fence !== null) {
551
678
  flushParagraph();
552
679
  const marker2 = fence[1] ?? "```";
@@ -556,16 +683,58 @@ function renderMarkdownToHtml(content, ctx) {
556
683
  while (index < lines.length) {
557
684
  const candidate = lines[index] ?? "";
558
685
  const close = /^\s{0,3}(`{3,}|~{3,})\s*$/u.exec(candidate);
559
- if (close !== null && (close[1] ?? "")[0] === marker2[0] && (close[1] ?? "").length >= marker2.length) {
686
+ if (close !== null && (close[1] ?? "")[0] === marker2[0] && (close[1] ?? "").length >= marker2.length)
560
687
  break;
561
- }
562
688
  body.push(candidate);
563
689
  index += 1;
564
690
  }
565
691
  index += 1;
566
- const className = language === "" ? "" : ` class="language-${escapeAttribute(language)}"`;
567
- html.push(`<pre><code${className}>${escapeHtml(body.join(`
568
- `))}</code></pre>`);
692
+ blocks.push({ type: "code", language, text: body.join(`
693
+ `) });
694
+ continue;
695
+ }
696
+ if (comments.closing === undefined && !inCode && /^(?: {4}|\t)/u.test(raw)) {
697
+ flushParagraph();
698
+ const body = [];
699
+ while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
700
+ body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
701
+ index += 1;
702
+ }
703
+ blocks.push({ type: "code", language: "", text: body.join(`
704
+ `).replace(/\n+$/u, "") });
705
+ continue;
706
+ }
707
+ const rawDefinition = comments.closing === undefined && !inCode ? /^ {0,3}\[\^([^\]\n]*)\]:/u.exec(raw) : null;
708
+ if (rawDefinition !== null) {
709
+ flushParagraph();
710
+ const authored = [raw];
711
+ index += 1;
712
+ while (index < lines.length) {
713
+ const next = lines[index] ?? "";
714
+ if (/^(?: {4}|\t)/u.test(next) || next.trim() === "" && /^(?: {4}|\t)/u.test(lines[index + 1] ?? "")) {
715
+ authored.push(next);
716
+ index += 1;
717
+ } else {
718
+ break;
719
+ }
720
+ }
721
+ const clean = withoutComments(authored.join(`
722
+ `), comments).split(`
723
+ `);
724
+ const definition = /^ {0,3}\[\^([^\]\n]*)\]:[ \t]*(.*)$/u.exec(clean[0] ?? "");
725
+ if (!topLevel || definition === null) {
726
+ blocks.push({ type: "literal", text: clean.join(`
727
+ `) });
728
+ } else {
729
+ const body = [definition[2] ?? "", ...clean.slice(1).map((line2) => line2.replace(/^(?: {4}|\t)/u, ""))];
730
+ blocks.push({ type: "footnote", note: admitFootnote(rawDefinition[1] ?? "", body, clean, footnotes) });
731
+ }
732
+ continue;
733
+ }
734
+ const line = comments.closing === undefined && !inCode && /^\s{0,3}>/u.test(raw) ? raw : withoutComments(raw, comments, paragraphBoundary(raw, lines[index + 1] ?? "") ? undefined : (ticks) => hasContinuedInlineCode(lines, index + 1, ticks));
735
+ if (inCode) {
736
+ paragraph.push(line);
737
+ index += 1;
569
738
  continue;
570
739
  }
571
740
  if (line.trim() === "") {
@@ -576,117 +745,164 @@ function renderMarkdownToHtml(content, ctx) {
576
745
  const heading = /^(#{1,6})\s+(.*)$/u.exec(line);
577
746
  if (heading !== null) {
578
747
  flushParagraph();
579
- const level = (heading[1] ?? "#").length;
580
- const body = renderInline((heading[2] ?? "").replace(/\s+#+$/u, ""), ctx);
581
- const id = anchorId(stripMarkup(body), anchors);
582
- html.push(`<h${level} id="${escapeAttribute(id)}">${body}</h${level}>`);
748
+ blocks.push({ type: "heading", level: (heading[1] ?? "#").length, text: (heading[2] ?? "").replace(/\s+#+$/u, "") });
583
749
  index += 1;
584
750
  continue;
585
751
  }
586
752
  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)) {
587
753
  flushParagraph();
588
- html.push("<hr>");
754
+ blocks.push({ type: "break" });
589
755
  index += 1;
590
756
  continue;
591
757
  }
592
758
  if (/^\s{0,3}>/u.test(line)) {
593
759
  flushParagraph();
594
- const quote = [];
760
+ const quote = [line.replace(/^\s{0,3}>\s?/u, "")];
761
+ index += 1;
595
762
  while (index < lines.length && /^\s{0,3}>/u.test(lines[index] ?? "")) {
596
763
  quote.push((lines[index] ?? "").replace(/^\s{0,3}>\s?/u, ""));
597
764
  index += 1;
598
765
  }
599
- html.push(`<blockquote>${renderMarkdownToHtml(quote.join(`
600
- `), ctx)}</blockquote>`);
766
+ blocks.push({ type: "quote", blocks: parseMarkdownBlocks(quote.join(`
767
+ `), footnotes, false) });
601
768
  continue;
602
769
  }
603
770
  if (line.includes("|") && index + 1 < lines.length && isTableDivider(lines[index + 1] ?? "")) {
604
771
  flushParagraph();
605
772
  const header = tableCells(line);
606
- index += 2;
607
773
  const rows = [];
774
+ index += 2;
608
775
  while (index < lines.length && (lines[index] ?? "").includes("|") && (lines[index] ?? "").trim() !== "") {
609
- rows.push(lines[index] ?? "");
776
+ const row = withoutComments(lines[index] ?? "", comments);
777
+ if (row.trim() !== "")
778
+ rows.push(tableCells(row));
610
779
  index += 1;
611
780
  }
612
- const head = header.map((cell) => `<th>${renderInline(cell, ctx)}</th>`).join("");
613
- const bodyRows = rows.map((row) => `<tr>${tableCells(row).map((cell) => `<td>${renderInline(cell, ctx)}</td>`).join("")}</tr>`).join("");
614
- html.push(`<table><thead><tr>${head}</tr></thead><tbody>${bodyRows}</tbody></table>`);
781
+ blocks.push({ type: "table", header, rows });
615
782
  continue;
616
783
  }
617
784
  const marker = listMarker(line);
618
785
  if (marker !== undefined) {
619
786
  flushParagraph();
620
- const items = [];
621
- let ordered = marker.ordered;
787
+ const parts = [];
622
788
  const baseIndent = marker.indent;
623
789
  const stack = [];
790
+ let currentLine = line;
624
791
  while (index < lines.length) {
625
- const current = listMarker(lines[index] ?? "");
626
- const raw = lines[index] ?? "";
792
+ const current = listMarker(currentLine);
627
793
  if (current === undefined) {
628
- if (raw.trim() === "") {
794
+ if (currentLine.trim() === "") {
629
795
  index += 1;
630
796
  break;
631
797
  }
632
- const continuationIndent = raw.length - raw.trimStart().length;
633
- if (items.length > 0 && continuationIndent > baseIndent) {
634
- items.push(`
635
- ${renderInline(raw.trim(), ctx)}`);
798
+ const continuationIndent = currentLine.length - currentLine.trimStart().length;
799
+ if (parts.length > 0 && continuationIndent > baseIndent) {
800
+ parts.push({ type: "continuation", text: currentLine.trim() });
636
801
  index += 1;
637
- continue;
802
+ } else {
803
+ break;
638
804
  }
639
- break;
640
- }
641
- const body = raw.slice(raw.search(/\S/u));
642
- const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
643
- const task = /^\[([ xX])\]\s+/u.exec(itemBody);
644
- const content2 = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
645
- const checkbox = task === null ? "" : `<input type="checkbox" disabled${(task[1] ?? " ").toLowerCase() === "x" ? " checked" : ""}> `;
646
- const top = stack[stack.length - 1];
647
- if (current.indent > (top?.indent ?? baseIndent) && stack.length < 8) {
648
- stack.push({ indent: current.indent, ordered: current.ordered });
649
- items.push(`<${current.ordered ? "ol" : "ul"}>`);
650
- }
651
- while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
652
- const popped = stack.pop();
653
- items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
805
+ } else {
806
+ const body = currentLine.slice(currentLine.search(/\S/u));
807
+ const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
808
+ const task = /^\[([ xX])\]\s+/u.exec(itemBody);
809
+ const text = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
810
+ if (current.indent > (stack[stack.length - 1]?.indent ?? baseIndent) && stack.length < 8) {
811
+ stack.push({ indent: current.indent, ordered: current.ordered });
812
+ parts.push({ type: "open", ordered: current.ordered });
813
+ }
814
+ while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
815
+ parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
816
+ }
817
+ parts.push({ type: "item", text, ...task === null ? {} : { checked: (task[1] ?? " ").toLowerCase() === "x" } });
818
+ index += 1;
654
819
  }
655
- items.push(`<li>${checkbox}${renderInline(content2, ctx)}</li>`);
656
- index += 1;
657
- }
658
- while (stack.length > 0) {
659
- const popped = stack.pop();
660
- items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
661
- }
662
- html.push(`<${ordered ? "ol" : "ul"}>${items.join("")}</${ordered ? "ol" : "ul"}>`);
663
- continue;
664
- }
665
- const commentStart = line.indexOf("<!--");
666
- if (commentStart !== -1) {
667
- const close = line.indexOf("-->", commentStart + 4);
668
- if (close === -1)
669
- inComment = true;
670
- index += 1;
671
- continue;
672
- }
673
- if (/^(?: {4}|\t)/u.test(line)) {
674
- flushParagraph();
675
- const body = [];
676
- while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
677
- body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
678
- index += 1;
820
+ const next = lines[index] ?? "";
821
+ if (listMarker(next) === undefined && next.trim() !== "" && next.length - next.trimStart().length <= baseIndent)
822
+ break;
823
+ currentLine = withoutComments(next, comments);
679
824
  }
680
- html.push(`<pre><code>${escapeHtml(body.join(`
681
- `).replace(/\n+$/u, ""))}</code></pre>`);
825
+ while (stack.length > 0)
826
+ parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
827
+ blocks.push({ type: "list", ordered: marker.ordered, parts });
682
828
  continue;
683
829
  }
684
830
  paragraph.push(line);
685
831
  index += 1;
686
832
  }
687
833
  flushParagraph();
688
- return html.join(`
834
+ return blocks;
835
+ }
836
+ function renderBlocks(blocks, ctx, footnotes, anchors) {
837
+ return blocks.map((block) => {
838
+ switch (block.type) {
839
+ case "paragraph":
840
+ return `<p>${renderInline(block.text, ctx, footnotes)}</p>`;
841
+ case "literal":
842
+ return `<p>${escapeHtml(block.text).replaceAll(`
843
+ `, "<br>")}</p>`;
844
+ case "heading": {
845
+ const body = renderInline(block.text, ctx, footnotes);
846
+ return `<h${block.level} id="${escapeAttribute(anchorId(stripMarkup(body), anchors))}">${body}</h${block.level}>`;
847
+ }
848
+ case "code":
849
+ return `<pre><code${block.language === "" ? "" : ` class="language-${escapeAttribute(block.language)}"`}>${escapeHtml(block.text)}</code></pre>`;
850
+ case "break":
851
+ return "<hr>";
852
+ case "quote":
853
+ return `<blockquote>${renderBlocks(block.blocks, ctx, footnotes, anchors)}</blockquote>`;
854
+ case "table": {
855
+ const header = block.header.map((cell) => `<th>${renderInline(cell, ctx, footnotes)}</th>`).join("");
856
+ const rows = block.rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, ctx, footnotes)}</td>`).join("")}</tr>`).join("");
857
+ return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
858
+ }
859
+ case "list": {
860
+ const parts = block.parts.map((part) => {
861
+ switch (part.type) {
862
+ case "open":
863
+ return `<${part.ordered ? "ol" : "ul"}>`;
864
+ case "close":
865
+ return `</${part.ordered ? "ol" : "ul"}>`;
866
+ case "continuation":
867
+ return `
868
+ ${renderInline(part.text, ctx, footnotes)}`;
869
+ case "item":
870
+ return `<li>${part.checked === undefined ? "" : `<input type="checkbox" disabled${part.checked ? " checked" : ""}> `}${renderInline(part.text, ctx, footnotes)}</li>`;
871
+ }
872
+ }).join("");
873
+ return `<${block.ordered ? "ol" : "ul"}>${parts}</${block.ordered ? "ol" : "ul"}>`;
874
+ }
875
+ case "footnote":
876
+ return footnotes.disabled || block.note.invalid ? `<p class="unresolved footnote-definition">${escapeHtml(block.note.authored).replaceAll(`
877
+ `, "<br>")}</p>` : "";
878
+ }
879
+ }).filter((html) => html !== "").join(`
880
+ `);
881
+ }
882
+ function renderMarkdownToHtml(content, ctx) {
883
+ const footnotes = { byLabel: new Map, numbered: [], definitions: 0, references: 0, disabled: false };
884
+ const blocks = parseMarkdownBlocks(content, footnotes, true);
885
+ const body = renderBlocks(blocks, ctx, footnotes, new Map);
886
+ if (footnotes.disabled)
887
+ return body;
888
+ for (const note of footnotes.byLabel.values()) {
889
+ if (note !== null && !note.invalid)
890
+ footnoteNumber(note, footnotes);
891
+ }
892
+ if (footnotes.numbered.length === 0)
893
+ return body;
894
+ const notes = footnotes.numbered.map((note) => {
895
+ const number = note.number;
896
+ const backlinks = note.references.map((id, index) => `<a href="#${id}" role="doc-backlink" aria-label="Back to reference ${index + 1} for footnote ${number}">\u21A9 ${index + 1}</a>`).join(" ");
897
+ const text = renderInline(note.body.replaceAll(`
898
+ `, " "), ctx);
899
+ return `<li id="wordcell:footnote:${number}" tabindex="-1">${text}${backlinks === "" ? "" : ` ${backlinks}`}</li>`;
900
+ }).join(`
689
901
  `);
902
+ return `${body}${body === "" ? "" : `
903
+ `}<section class="footnotes" aria-label="Footnotes"><ol>
904
+ ${notes}
905
+ </ol></section>`;
690
906
  }
691
907
  function stripMarkup(html) {
692
908
  let stripped = "";
@@ -100,7 +100,7 @@ import {
100
100
  MAX_PUBLISH_LIST_LIMIT,
101
101
  publishVault,
102
102
  renderPublishReportText
103
- } from "./index-hmw17zaa.js";
103
+ } from "./index-a3j61wz9.js";
104
104
  import {
105
105
  refreshVault,
106
106
  scanVault
package/dist/index.js CHANGED
@@ -167,7 +167,7 @@ import {
167
167
  publishVault,
168
168
  renderPublishReportText,
169
169
  serializeSiteFile
170
- } from "./index-hmw17zaa.js";
170
+ } from "./index-a3j61wz9.js";
171
171
  import {
172
172
  MAX_NOTE_UTF8_BYTES,
173
173
  MAX_SCANNED_NOTES,
package/dist/publish.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  publishVault,
11
11
  renderPublishReportText,
12
12
  serializeSiteFile
13
- } from "./index-hmw17zaa.js";
13
+ } from "./index-a3j61wz9.js";
14
14
  import"./index-233z9wmn.js";
15
15
  import"./index-d13v9ckt.js";
16
16
  import"./index-48pz4jpc.js";
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  parseArguments as parseArguments2,
4
4
  parseUrlMetadataArguments
5
- } from "./index-bw2r37z9.js";
5
+ } from "./index-yx6p1vzc.js";
6
6
  import"./index-bcknqxrq.js";
7
7
  import {
8
8
  parsePdfArguments
@@ -42,7 +42,7 @@ import"./index-tf4wzjew.js";
42
42
  import"./index-1gwbassd.js";
43
43
  import"./index-vnybgywh.js";
44
44
  import"./index-1xxnjn0d.js";
45
- import"./index-hmw17zaa.js";
45
+ import"./index-a3j61wz9.js";
46
46
  import"./index-233z9wmn.js";
47
47
  import"./index-d13v9ckt.js";
48
48
  import"./index-48pz4jpc.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/wordcell",
3
- "version": "0.22.2",
3
+ "version": "0.22.3",
4
4
  "description": "A local Markdown knowledge base with superpowers. Save decisions, sources, and plans as files you own, then recover them through exact and semantic search, typed relationships, backlinks, and Git history.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -479,8 +479,8 @@
479
479
  "check": "bun run check:worker && bun run check:effect && bun run check:workflows && bun run check:portfolio-inventory && bun run check:installed-command-docs && bun run typecheck && bun run build && bun run test:package && bun run test && bun run url-metadata:tool:check",
480
480
  "prepack": "bun run check",
481
481
  "check:effect": "bun run scripts/check-effect-policy.ts",
482
- "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.1/hraness-wordcell-0.22.1.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
483
- "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.1/hraness-wordcell-0.22.1.tgz wordcell search --root kb --mode exact",
482
+ "kb:search": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact --rerank typesafe --rerank-limit 25",
483
+ "kb:search:local": "bunx --bun --package https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz wordcell search --root kb --mode exact",
484
484
  "check:worker": "tsc -p worker/tsconfig.json --noEmit"
485
485
  },
486
486
  "dependencies": {
@@ -56,7 +56,7 @@ missing:
56
56
  ```sh
57
57
  command -v wordcell >/dev/null 2>&1 || {
58
58
  command -v bun >/dev/null 2>&1 || exit 1
59
- bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.2/hraness-wordcell-0.22.2.tgz
59
+ bun add --global --ignore-scripts https://github.com/hraness/wordcell/releases/download/v0.22.3/hraness-wordcell-0.22.3.tgz
60
60
  }
61
61
  wordcell --help
62
62
  ```
@@ -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,60 @@ 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
+ };
112
+
113
+ function footnoteLabel(raw: string): string | undefined {
114
+ const label = raw.normalize("NFC");
115
+ if (label.length > FOOTNOTE_LIMITS.labelBytes || !/^[\p{L}\p{M}\p{N}._:-]+$/u.test(label)) return undefined;
116
+ return new TextEncoder().encode(label).byteLength <= FOOTNOTE_LIMITS.labelBytes ? label : undefined;
117
+ }
118
+
119
+ function footnoteNumber(note: Footnote, footnotes: Footnotes): number {
120
+ if (note.number === undefined) {
121
+ note.number = footnotes.numbered.length + 1;
122
+ footnotes.numbered.push(note);
123
+ }
124
+ return note.number;
125
+ }
126
+
127
+ function renderFootnoteReference(raw: string, label: string, footnotes: Footnotes): string {
128
+ const key = footnoteLabel(label);
129
+ const note = key === undefined ? undefined : footnotes.byLabel.get(key);
130
+ if (footnotes.disabled || note === undefined || note === null || note.invalid || footnotes.references >= FOOTNOTE_LIMITS.references) {
131
+ return `<span class="unresolved footnote-reference" aria-label="Unresolved footnote">${escapeHtml(raw)}</span>`;
132
+ }
133
+ const number = footnoteNumber(note, footnotes);
134
+ // Colons cannot occur in heading slugs, so authored headings cannot capture
135
+ // these targets. Raw labels never enter an HTML id or fragment.
136
+ const id = `wordcell:footnote-ref:${number}:${note.references.length + 1}`;
137
+ note.references.push(id);
138
+ footnotes.references += 1;
139
+ return `<sup><a id="${id}" href="#wordcell:footnote:${number}" role="doc-noteref" aria-label="Footnote ${number}">${number}</a></sup>`;
140
+ }
141
+
142
+ function renderInline(text: string, ctx: PublishRenderContext, footnotes?: Footnotes): string {
84
143
  let output = "";
85
144
  let cursor = 0;
86
145
  const length = text.length;
@@ -99,7 +158,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
99
158
  if (closing !== -1) {
100
159
  const code = rest.slice(ticks.length, closing).replace(/\s+/gu, " ");
101
160
  output += `<code>${escapeHtml(code)}</code>`;
102
- cursor += ticks.length + closing + ticks.length;
161
+ cursor += closing + ticks.length;
103
162
  continue;
104
163
  }
105
164
  pushText("`");
@@ -198,6 +257,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
198
257
  if (closing !== -1 && rest[closing + 1] === "(") {
199
258
  const destination = readDestination(rest, closing + 2);
200
259
  if (destination !== null) {
260
+ // Supported explicit links take precedence over footnote markers.
201
261
  const label = renderInline(rest.slice(1, closing), ctx);
202
262
  const target = destination.target;
203
263
  const asset = ctx.resolveAsset(target, ctx.source);
@@ -219,6 +279,14 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
219
279
  continue;
220
280
  }
221
281
  }
282
+ if (footnotes !== undefined && rest.startsWith("[^")) {
283
+ const end = rest.indexOf("]", 2);
284
+ if (end !== -1) {
285
+ output += renderFootnoteReference(rest.slice(0, end + 1), rest.slice(2, end), footnotes);
286
+ cursor += end + 1;
287
+ continue;
288
+ }
289
+ }
222
290
  pushText("[");
223
291
  cursor += 1;
224
292
  continue;
@@ -249,7 +317,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
249
317
  if (emphasis !== undefined && emphasis !== "_") {
250
318
  const closing = rest.indexOf(emphasis, emphasis.length);
251
319
  if (closing > emphasis.length) {
252
- const inner = renderInline(rest.slice(emphasis.length, closing), ctx);
320
+ const inner = renderInline(rest.slice(emphasis.length, closing), ctx, footnotes);
253
321
  const tag = emphasis === "**" || emphasis === "__"
254
322
  ? "strong"
255
323
  : emphasis === "~~"
@@ -265,7 +333,7 @@ function renderInline(text: string, ctx: PublishRenderContext): string {
265
333
  if (emphasis === "_") {
266
334
  const closing = rest.indexOf("_", 1);
267
335
  if (closing > 1) {
268
- output += `<em>${renderInline(rest.slice(1, closing), ctx)}</em>`;
336
+ output += `<em>${renderInline(rest.slice(1, closing), ctx, footnotes)}</em>`;
269
337
  cursor += closing + 1;
270
338
  continue;
271
339
  }
@@ -398,49 +466,145 @@ function tableCells(line: string): string[] {
398
466
  return cells.map((cell) => cell.trim());
399
467
  }
400
468
 
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;
469
+ type ListPart =
470
+ | { readonly type: "open" | "close"; readonly ordered: boolean }
471
+ | { readonly type: "item"; readonly text: string; readonly checked?: boolean }
472
+ | { readonly type: "continuation"; readonly text: string };
473
+
474
+ type MarkdownBlock =
475
+ | { readonly type: "paragraph"; readonly text: string }
476
+ | { readonly type: "heading"; readonly level: number; readonly text: string }
477
+ | { readonly type: "code"; readonly language: string; readonly text: string }
478
+ | { readonly type: "break" }
479
+ | { readonly type: "quote"; readonly blocks: readonly MarkdownBlock[] }
480
+ | { readonly type: "table"; readonly header: readonly string[]; readonly rows: readonly string[][] }
481
+ | { readonly type: "list"; readonly ordered: boolean; readonly parts: readonly ListPart[] }
482
+ | { readonly type: "literal"; readonly text: string }
483
+ | { readonly type: "footnote"; readonly note: Footnote };
484
+
485
+ type Comments = { closing?: "-->" | "%%"; code?: string };
486
+
487
+ /** Strip comments only in prose; code blocks never pass through this scanner. */
488
+ function withoutComments(line: string, comments: Comments, continuesCode?: (ticks: string) => boolean): string {
489
+ let text = "";
490
+ let cursor = 0;
491
+ while (cursor < line.length) {
492
+ if (comments.code !== undefined) {
493
+ const end = line.indexOf(comments.code, cursor);
494
+ if (end === -1) return text + line.slice(cursor);
495
+ const next = end + comments.code.length;
496
+ text += line.slice(cursor, next);
497
+ cursor = next;
498
+ delete comments.code;
499
+ continue;
500
+ }
501
+ if (comments.closing !== undefined) {
502
+ const end = line.indexOf(comments.closing, cursor);
503
+ if (end === -1) return text;
504
+ cursor = end + comments.closing.length;
505
+ delete comments.closing;
506
+ continue;
507
+ }
508
+ const rest = line.slice(cursor);
509
+ if (rest[0] === "\\" && rest.length > 1) {
510
+ text += rest.slice(0, 2);
511
+ cursor += 2;
512
+ continue;
513
+ }
514
+ if (rest[0] === "`") {
515
+ const ticks = /^`+/u.exec(rest)?.[0] ?? "`";
516
+ const closing = rest.indexOf(ticks, ticks.length);
517
+ if (closing !== -1) {
518
+ const end = closing + ticks.length;
519
+ text += rest.slice(0, end);
520
+ cursor += end;
521
+ continue;
522
+ }
523
+ if (continuesCode?.(ticks) === true) {
524
+ comments.code = ticks;
525
+ return text + rest;
411
526
  }
412
527
  }
528
+ if (rest.startsWith("<!--") || rest.startsWith("%%")) {
529
+ comments.closing = rest.startsWith("<!--") ? "-->" : "%%";
530
+ cursor += comments.closing === "-->" ? 4 : 2;
531
+ continue;
532
+ }
533
+ text += rest[0] ?? "";
534
+ cursor += 1;
535
+ }
536
+ return text;
537
+ }
538
+
539
+ function paragraphBoundary(line: string, next: string): boolean {
540
+ return line.trim() === "" || /^(?: {4}|\t|#{1,6}\s|\s{0,3}>|\s{0,3}(?:`{3,}|~{3,}))/u.test(line)
541
+ || listMarker(line) !== undefined || (line.includes("|") && isTableDivider(next))
542
+ || /^\s{0,3}(?:(?:\*\s*){3,}|(?:-\s*){3,}|(?:_\s*){3,})$/u.test(line);
543
+ }
544
+
545
+ function hasContinuedInlineCode(lines: readonly string[], start: number, ticks: string): boolean {
546
+ for (let index = start; index < lines.length; index += 1) {
547
+ const line = lines[index] ?? "";
548
+ if (paragraphBoundary(line, lines[index + 1] ?? "")) return false;
549
+ if (line.includes(ticks)) return true;
413
550
  }
551
+ return false;
552
+ }
414
553
 
415
- const anchors = new Map<string, number>();
416
- const html: string[] = [];
417
- let index = start;
418
- let inComment = false;
554
+ function admitFootnote(rawLabel: string, body: readonly string[], authored: readonly string[], footnotes: Footnotes): Footnote {
555
+ const text = body.join("\n");
556
+ const label = footnoteLabel(rawLabel);
557
+ const note: Footnote = {
558
+ body: text,
559
+ authored: authored.join("\n"),
560
+ invalid: label === undefined || body.length > FOOTNOTE_LIMITS.bodyLines || text.length > FOOTNOTE_LIMITS.bodyBytes
561
+ || new TextEncoder().encode(text).byteLength > FOOTNOTE_LIMITS.bodyBytes || text.trim() === "",
562
+ references: [],
563
+ };
564
+ footnotes.definitions += 1;
565
+ if (footnotes.definitions > FOOTNOTE_LIMITS.definitions) {
566
+ footnotes.disabled = true;
567
+ footnotes.byLabel.clear();
568
+ }
569
+ if (!footnotes.disabled && label !== undefined) {
570
+ if (footnotes.byLabel.has(label)) {
571
+ const prior = footnotes.byLabel.get(label);
572
+ if (prior !== undefined && prior !== null) prior.invalid = true;
573
+ note.invalid = true;
574
+ footnotes.byLabel.set(label, null);
575
+ } else {
576
+ footnotes.byLabel.set(label, note.invalid ? null : note);
577
+ }
578
+ }
579
+ return note;
580
+ }
419
581
 
582
+ /** One block scan owns both rendering precedence and definition admission. */
583
+ function parseMarkdownBlocks(content: string, footnotes: Footnotes, topLevel: boolean): MarkdownBlock[] {
584
+ const lines = content.split("\n");
585
+ let index = 0;
586
+ if (topLevel && lines[0]?.trim() === "---") {
587
+ for (let cursor = 1; cursor < lines.length; cursor += 1) {
588
+ if (lines[cursor]?.trim() === "---" || lines[cursor]?.trim() === "...") {
589
+ index = cursor + 1;
590
+ break;
591
+ }
592
+ }
593
+ }
594
+ const blocks: MarkdownBlock[] = [];
595
+ const comments: Comments = {};
420
596
  const paragraph: string[] = [];
421
597
  const flushParagraph = (): void => {
422
598
  if (paragraph.length === 0) return;
423
- html.push(`<p>${renderInline(paragraph.join("\n").replace(/\n/gu, " "), ctx)}</p>`);
599
+ blocks.push({ type: "paragraph", text: paragraph.join(" ") });
424
600
  paragraph.length = 0;
425
601
  };
426
602
 
427
603
  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);
604
+ const raw = lines[index] ?? "";
605
+ // Code has precedence over prose comments and footnote definitions.
606
+ const inCode = comments.code !== undefined;
607
+ const fence = comments.closing === undefined && !inCode ? /^\s{0,3}(`{3,}|~{3,})([^`]*)$/u.exec(raw) : null;
444
608
  if (fence !== null) {
445
609
  flushParagraph();
446
610
  const marker = fence[1] ?? "```";
@@ -450,151 +614,210 @@ export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext)
450
614
  while (index < lines.length) {
451
615
  const candidate = lines[index] ?? "";
452
616
  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
- }
617
+ if (close !== null && (close[1] ?? "")[0] === marker[0] && (close[1] ?? "").length >= marker.length) break;
456
618
  body.push(candidate);
457
619
  index += 1;
458
620
  }
459
621
  index += 1;
460
- const className = language === "" ? "" : ` class="language-${escapeAttribute(language)}"`;
461
- html.push(`<pre><code${className}>${escapeHtml(body.join("\n"))}</code></pre>`);
622
+ blocks.push({ type: "code", language, text: body.join("\n") });
623
+ continue;
624
+ }
625
+ if (comments.closing === undefined && !inCode && /^(?: {4}|\t)/u.test(raw)) {
626
+ flushParagraph();
627
+ const body: string[] = [];
628
+ while (index < lines.length && /^(?: {4}|\t| *$)/u.test(lines[index] ?? "")) {
629
+ body.push((lines[index] ?? "").replace(/^(?: {4}|\t)/u, ""));
630
+ index += 1;
631
+ }
632
+ blocks.push({ type: "code", language: "", text: body.join("\n").replace(/\n+$/u, "") });
633
+ continue;
634
+ }
635
+ const rawDefinition = comments.closing === undefined && !inCode ? /^ {0,3}\[\^([^\]\n]*)\]:/u.exec(raw) : null;
636
+ if (rawDefinition !== null) {
637
+ flushParagraph();
638
+ const authored = [raw];
639
+ index += 1;
640
+ while (index < lines.length) {
641
+ const next = lines[index] ?? "";
642
+ if (/^(?: {4}|\t)/u.test(next) || (next.trim() === "" && /^(?: {4}|\t)/u.test(lines[index + 1] ?? ""))) {
643
+ authored.push(next);
644
+ index += 1;
645
+ } else {
646
+ break;
647
+ }
648
+ }
649
+ // Scan the full inline body together so comments inside a code span
650
+ // crossing continuation lines remain code, rather than disappearing.
651
+ const clean = withoutComments(authored.join("\n"), comments).split("\n");
652
+ const definition = /^ {0,3}\[\^([^\]\n]*)\]:[ \t]*(.*)$/u.exec(clean[0] ?? "");
653
+ if (!topLevel || definition === null) {
654
+ blocks.push({ type: "literal", text: clean.join("\n") });
655
+ } else {
656
+ const body = [definition[2] ?? "", ...clean.slice(1).map((line) => line.replace(/^(?: {4}|\t)/u, ""))];
657
+ blocks.push({ type: "footnote", note: admitFootnote(rawDefinition[1] ?? "", body, clean, footnotes) });
658
+ }
659
+ continue;
660
+ }
661
+ // Quoted blocks own their comment/code scope after removing the marker.
662
+ const line = comments.closing === undefined && !inCode && /^\s{0,3}>/u.test(raw)
663
+ ? raw
664
+ : withoutComments(raw, comments, paragraphBoundary(raw, lines[index + 1] ?? "")
665
+ ? undefined
666
+ : (ticks) => hasContinuedInlineCode(lines, index + 1, ticks));
667
+ if (inCode) {
668
+ paragraph.push(line);
669
+ index += 1;
462
670
  continue;
463
671
  }
464
-
465
- // Blank line.
466
672
  if (line.trim() === "") {
467
673
  flushParagraph();
468
674
  index += 1;
469
675
  continue;
470
676
  }
471
-
472
- // ATX heading.
473
677
  const heading = /^(#{1,6})\s+(.*)$/u.exec(line);
474
678
  if (heading !== null) {
475
679
  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}>`);
680
+ blocks.push({ type: "heading", level: (heading[1] ?? "#").length, text: (heading[2] ?? "").replace(/\s+#+$/u, "") });
480
681
  index += 1;
481
682
  continue;
482
683
  }
483
-
484
- // Thematic break.
485
684
  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
685
  flushParagraph();
487
- html.push("<hr>");
686
+ blocks.push({ type: "break" });
488
687
  index += 1;
489
688
  continue;
490
689
  }
491
-
492
- // Blockquote (one level per pass; nesting via > > accumulation).
493
690
  if (/^\s{0,3}>/u.test(line)) {
494
691
  flushParagraph();
495
- const quote: string[] = [];
692
+ const quote = [line.replace(/^\s{0,3}>\s?/u, "")];
693
+ index += 1;
496
694
  while (index < lines.length && /^\s{0,3}>/u.test(lines[index] ?? "")) {
497
695
  quote.push((lines[index] ?? "").replace(/^\s{0,3}>\s?/u, ""));
498
696
  index += 1;
499
697
  }
500
- html.push(`<blockquote>${renderMarkdownToHtml(quote.join("\n"), ctx)}</blockquote>`);
698
+ blocks.push({ type: "quote", blocks: parseMarkdownBlocks(quote.join("\n"), footnotes, false) });
501
699
  continue;
502
700
  }
503
-
504
- // Tables: header row, divider, body rows.
505
701
  if (line.includes("|") && index + 1 < lines.length && isTableDivider(lines[index + 1] ?? "")) {
506
702
  flushParagraph();
507
703
  const header = tableCells(line);
704
+ const rows: string[][] = [];
508
705
  index += 2;
509
- const rows: string[] = [];
510
706
  while (index < lines.length && (lines[index] ?? "").includes("|") && (lines[index] ?? "").trim() !== "") {
511
- rows.push(lines[index] ?? "");
707
+ const row = withoutComments(lines[index] ?? "", comments);
708
+ if (row.trim() !== "") rows.push(tableCells(row));
512
709
  index += 1;
513
710
  }
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>`);
711
+ blocks.push({ type: "table", header, rows });
518
712
  continue;
519
713
  }
520
-
521
- // Lists: flat scan with indent-based nesting.
522
714
  const marker = listMarker(line);
523
715
  if (marker !== undefined) {
524
716
  flushParagraph();
525
- const items: string[] = [];
526
- let ordered = marker.ordered;
717
+ const parts: ListPart[] = [];
527
718
  const baseIndent = marker.indent;
528
719
  const stack: { readonly indent: number; readonly ordered: boolean }[] = [];
720
+ let currentLine = line;
529
721
  while (index < lines.length) {
530
- const current = listMarker(lines[index] ?? "");
531
- const raw = lines[index] ?? "";
722
+ const current = listMarker(currentLine);
532
723
  if (current === undefined) {
533
- if (raw.trim() === "") {
724
+ if (currentLine.trim() === "") {
534
725
  index += 1;
535
726
  break;
536
727
  }
537
- const continuationIndent = raw.length - raw.trimStart().length;
538
- if (items.length > 0 && continuationIndent > baseIndent) {
539
- items.push(`\n${renderInline(raw.trim(), ctx)}`);
728
+ const continuationIndent = currentLine.length - currentLine.trimStart().length;
729
+ if (parts.length > 0 && continuationIndent > baseIndent) {
730
+ parts.push({ type: "continuation", text: currentLine.trim() });
540
731
  index += 1;
541
- continue;
732
+ } else {
733
+ break;
542
734
  }
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"}>`);
735
+ } else {
736
+ const body = currentLine.slice(currentLine.search(/\S/u));
737
+ const itemBody = body.replace(/^(?:[-+*]|\d{1,9}[.)])\s+/u, "");
738
+ const task = /^\[([ xX])\]\s+/u.exec(itemBody);
739
+ const text = task === null ? itemBody : itemBody.slice((task[0] ?? "").length);
740
+ if (current.indent > (stack[stack.length - 1]?.indent ?? baseIndent) && stack.length < 8) {
741
+ stack.push({ indent: current.indent, ordered: current.ordered });
742
+ parts.push({ type: "open", ordered: current.ordered });
743
+ }
744
+ while (stack.length > 0 && current.indent < (stack[stack.length - 1]?.indent ?? baseIndent)) {
745
+ parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
746
+ }
747
+ parts.push({ type: "item", text, ...(task === null ? {} : { checked: (task[1] ?? " ").toLowerCase() === "x" }) });
748
+ index += 1;
560
749
  }
561
- items.push(`<li>${checkbox}${renderInline(content, ctx)}</li>`);
562
- index += 1;
750
+ // Do not consume comments on a line that belongs to the next block.
751
+ const next = lines[index] ?? "";
752
+ if (listMarker(next) === undefined && next.trim() !== "" && next.length - next.trimStart().length <= baseIndent) break;
753
+ currentLine = withoutComments(next, comments);
563
754
  }
564
- while (stack.length > 0) {
565
- const popped = stack.pop();
566
- items.push(`</${popped?.ordered === true ? "ol" : "ul"}>`);
567
- }
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;
755
+ while (stack.length > 0) parts.push({ type: "close", ordered: stack.pop()?.ordered === true });
756
+ blocks.push({ type: "list", ordered: marker.ordered, parts });
578
757
  continue;
579
758
  }
759
+ paragraph.push(line);
760
+ index += 1;
761
+ }
762
+ flushParagraph();
763
+ return blocks;
764
+ }
580
765
 
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;
766
+ function renderBlocks(blocks: readonly MarkdownBlock[], ctx: PublishRenderContext, footnotes: Footnotes, anchors: Map<string, number>): string {
767
+ return blocks.map((block): string => {
768
+ switch (block.type) {
769
+ case "paragraph": return `<p>${renderInline(block.text, ctx, footnotes)}</p>`;
770
+ case "literal": return `<p>${escapeHtml(block.text).replaceAll("\n", "<br>")}</p>`;
771
+ case "heading": {
772
+ const body = renderInline(block.text, ctx, footnotes);
773
+ return `<h${block.level} id="${escapeAttribute(anchorId(stripMarkup(body), anchors))}">${body}</h${block.level}>`;
588
774
  }
589
- html.push(`<pre><code>${escapeHtml(body.join("\n").replace(/\n+$/u, ""))}</code></pre>`);
590
- continue;
775
+ case "code": return `<pre><code${block.language === "" ? "" : ` class="language-${escapeAttribute(block.language)}"`}>${escapeHtml(block.text)}</code></pre>`;
776
+ case "break": return "<hr>";
777
+ case "quote": return `<blockquote>${renderBlocks(block.blocks, ctx, footnotes, anchors)}</blockquote>`;
778
+ case "table": {
779
+ const header = block.header.map((cell) => `<th>${renderInline(cell, ctx, footnotes)}</th>`).join("");
780
+ const rows = block.rows.map((row) => `<tr>${row.map((cell) => `<td>${renderInline(cell, ctx, footnotes)}</td>`).join("")}</tr>`).join("");
781
+ return `<table><thead><tr>${header}</tr></thead><tbody>${rows}</tbody></table>`;
782
+ }
783
+ case "list": {
784
+ const parts = block.parts.map((part) => {
785
+ switch (part.type) {
786
+ case "open": return `<${part.ordered ? "ol" : "ul"}>`;
787
+ case "close": return `</${part.ordered ? "ol" : "ul"}>`;
788
+ case "continuation": return `\n${renderInline(part.text, ctx, footnotes)}`;
789
+ case "item": return `<li>${part.checked === undefined ? "" : `<input type="checkbox" disabled${part.checked ? " checked" : ""}> `}${renderInline(part.text, ctx, footnotes)}</li>`;
790
+ }
791
+ }).join("");
792
+ return `<${block.ordered ? "ol" : "ul"}>${parts}</${block.ordered ? "ol" : "ul"}>`;
793
+ }
794
+ case "footnote": return footnotes.disabled || block.note.invalid
795
+ ? `<p class="unresolved footnote-definition">${escapeHtml(block.note.authored).replaceAll("\n", "<br>")}</p>`
796
+ : "";
591
797
  }
798
+ }).filter((html) => html !== "").join("\n");
799
+ }
592
800
 
593
- paragraph.push(line);
594
- index += 1;
801
+ /** Render authored Markdown to a sanitized HTML fragment. */
802
+ export function renderMarkdownToHtml(content: string, ctx: PublishRenderContext): string {
803
+ const footnotes: Footnotes = { byLabel: new Map(), numbered: [], definitions: 0, references: 0, disabled: false };
804
+ const blocks = parseMarkdownBlocks(content, footnotes, true);
805
+ const body = renderBlocks(blocks, ctx, footnotes, new Map());
806
+ if (footnotes.disabled) return body;
807
+ // Keep unreferenced authored definitions visible after referenced notes.
808
+ for (const note of footnotes.byLabel.values()) {
809
+ if (note !== null && !note.invalid) footnoteNumber(note, footnotes);
595
810
  }
596
- flushParagraph();
597
- return html.join("\n");
811
+ if (footnotes.numbered.length === 0) return body;
812
+ const notes = footnotes.numbered.map((note) => {
813
+ const number = note.number;
814
+ 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(" ");
815
+ // Definitions are inline prose. Footnote syntax inside them stays literal,
816
+ // so cycles cannot expand and every backlink corresponds to authored prose.
817
+ const text = renderInline(note.body.replaceAll("\n", " "), ctx);
818
+ return `<li id="wordcell:footnote:${number}" tabindex="-1">${text}${backlinks === "" ? "" : ` ${backlinks}`}</li>`;
819
+ }).join("\n");
820
+ return `${body}${body === "" ? "" : "\n"}<section class="footnotes" aria-label="Footnotes"><ol>\n${notes}\n</ol></section>`;
598
821
  }
599
822
 
600
823
  export function stripMarkup(html: string): string {