@gmickel/gno 2.8.2 → 2.8.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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/browser-extension/artifacts/{gno-browser-clipper-v2.8.2.zip → gno-browser-clipper-v2.8.3.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v2.8.3.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +9 -1
  6. package/spec/cli.md +66 -4
  7. package/spec/mcp.md +5 -6
  8. package/src/cli/commands/daemon.ts +7 -2
  9. package/src/cli/commands/doctor.ts +42 -24
  10. package/src/cli/commands/index-cmd.ts +10 -2
  11. package/src/cli/commands/links.ts +5 -22
  12. package/src/cli/commands/shared.ts +26 -1
  13. package/src/cli/commands/status.ts +60 -0
  14. package/src/cli/commands/update.ts +6 -1
  15. package/src/cli/commands/vec.ts +42 -2
  16. package/src/cli/program.ts +2 -2
  17. package/src/config/types.ts +19 -0
  18. package/src/converters/adapters/markitdownTs/adapter.ts +1 -52
  19. package/src/converters/adapters/shared/ooxml-protection.ts +58 -0
  20. package/src/converters/adapters/xlsx/adapter.ts +132 -0
  21. package/src/converters/adapters/xlsx/sheet-markdown.ts +312 -0
  22. package/src/converters/registry.ts +62 -6
  23. package/src/converters/versions.ts +2 -0
  24. package/src/core/audit-freshness.ts +1 -1
  25. package/src/core/audit-outside-index.ts +48 -3
  26. package/src/core/change-diff.ts +16 -4
  27. package/src/core/link-destination-parse.ts +21 -4
  28. package/src/core/link-inventory-markdown.ts +23 -3
  29. package/src/core/links.ts +74 -16
  30. package/src/core/network-boundary-inventory.ts +18 -0
  31. package/src/index.ts +10 -0
  32. package/src/ingestion/document-metadata.ts +256 -0
  33. package/src/ingestion/file-child-env.ts +9 -0
  34. package/src/ingestion/file-child.ts +106 -0
  35. package/src/ingestion/file-processor.ts +546 -0
  36. package/src/ingestion/frontmatter.ts +6 -2
  37. package/src/ingestion/index.ts +1 -0
  38. package/src/ingestion/prepare-convert.ts +32 -0
  39. package/src/ingestion/prepare-file.ts +272 -0
  40. package/src/ingestion/prepare-worker.ts +43 -0
  41. package/src/ingestion/record-container.ts +7 -2
  42. package/src/ingestion/strip.ts +71 -8
  43. package/src/ingestion/sync-options.ts +7 -1
  44. package/src/ingestion/sync.ts +207 -400
  45. package/src/ingestion/types.ts +18 -0
  46. package/src/llm/native-worker/embedding-identity.ts +15 -3
  47. package/src/mcp/http-transport.ts +11 -0
  48. package/src/mcp/tools/links.ts +10 -38
  49. package/src/serve/resident-request.ts +19 -0
  50. package/src/serve/resident-runtime.ts +84 -8
  51. package/src/serve/routes/links.ts +6 -24
  52. package/src/serve/server.ts +3 -2
  53. package/src/sessions/config-refresh.ts +3 -2
  54. package/src/store/sqlite/graph-similarity.ts +5 -13
  55. package/src/store/vector/partition-index.ts +100 -0
  56. package/src/store/vector/stored-vectors.ts +20 -15
  57. package/browser-extension/artifacts/gno-browser-clipper-v2.8.2.zip.sha256 +0 -1
@@ -3655,7 +3655,7 @@ function wireVecCommands(program: Command): void {
3655
3655
  addWriteLeaseFlags(
3656
3656
  vecCmd
3657
3657
  .command("sync")
3658
- .description("Sync vec0 index with content_vectors")
3658
+ .description("Sync the vec0 index with the active vector partition")
3659
3659
  .option("--json", "JSON output")
3660
3660
  ).action(async (cmdOpts: Record<string, unknown>) => {
3661
3661
  const format = getFormat(cmdOpts);
@@ -3728,7 +3728,7 @@ function wireVecCommands(program: Command): void {
3728
3728
  addWriteLeaseFlags(
3729
3729
  vecCmd
3730
3730
  .command("rebuild")
3731
- .description("Rebuild vec0 index from content_vectors")
3731
+ .description("Rebuild the vec0 index from the active vector partition")
3732
3732
  .option("--json", "JSON output")
3733
3733
  ).action(async (cmdOpts: Record<string, unknown>) => {
3734
3734
  const format = getFormat(cmdOpts);
@@ -429,6 +429,22 @@ export const DEFAULT_MODEL_PRESETS: ModelPreset[] = [
429
429
  },
430
430
  ];
431
431
 
432
+ /**
433
+ * Per-file conversion budget. A file that overruns either limit is recorded
434
+ * with TIMEOUT or MEMORY_LIMIT and retried by the next update.
435
+ */
436
+ export const ConversionConfigSchema = z
437
+ .object({
438
+ /** Wall-clock budget for converting one file, in ms (default 60000). */
439
+ timeoutMs: z.number().int().min(1000).max(86_400_000).optional(),
440
+ /**
441
+ * Resident-memory ceiling while a file converts, in MB (default: half
442
+ * of physical memory, at least 2048).
443
+ */
444
+ maxMemoryMb: z.number().int().min(256).max(1_048_576).optional(),
445
+ })
446
+ .strict();
447
+
432
448
  export const ModelConfigSchema = z.object({
433
449
  /** Active preset ID */
434
450
  activePreset: z.string().default("slim-tuned"),
@@ -608,6 +624,9 @@ export const ConfigSchema = z.object({
608
624
  /** Optional index-wide chunk size/overlap; omitted preserves legacy defaults. */
609
625
  chunking: ChunkingConfigSchema.optional(),
610
626
 
627
+ /** Per-file conversion budget; omitted fields use the runtime defaults. */
628
+ conversion: ConversionConfigSchema.optional(),
629
+
611
630
  /** Model configuration */
612
631
  models: ModelConfigSchema.optional(),
613
632
 
@@ -19,6 +19,7 @@ import {
19
19
  tooLargeError,
20
20
  } from "../../errors";
21
21
  import { ADAPTER_VERSIONS } from "../../versions";
22
+ import { hasPrefix, isPasswordProtectedXlsx } from "../shared/ooxml-protection";
22
23
 
23
24
  const CONVERTER_ID = "adapter/markitdown-ts" as const;
24
25
  const CONVERTER_VERSION = ADAPTER_VERSIONS["markitdown-ts"];
@@ -34,11 +35,6 @@ const SUPPORTED_MIMES = [
34
35
  ];
35
36
 
36
37
  const PDF_SIGNATURE = new Uint8Array([0x25, 0x50, 0x44, 0x46, 0x2d]);
37
- const CFB_SIGNATURE = new Uint8Array([
38
- 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
39
- ]);
40
- const ENCRYPTION_INFO = utf16le("EncryptionInfo");
41
- const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
42
38
  const PDF_TRAILER_SCAN_BYTES = 2048;
43
39
  const MAX_MESSAGE_LENGTH = 200;
44
40
  const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
@@ -51,45 +47,6 @@ function toBuffer(bytes: Uint8Array): Buffer {
51
47
  return Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
52
48
  }
53
49
 
54
- function utf16le(value: string): Uint8Array {
55
- return new Uint8Array(Buffer.from(value, "utf16le"));
56
- }
57
-
58
- function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
59
- if (bytes.length < prefix.length) {
60
- return false;
61
- }
62
-
63
- for (let index = 0; index < prefix.length; index += 1) {
64
- if (bytes[index] !== prefix[index]) {
65
- return false;
66
- }
67
- }
68
-
69
- return true;
70
- }
71
-
72
- function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
73
- if (needle.length === 0 || bytes.length < needle.length) {
74
- return false;
75
- }
76
-
77
- outer: for (
78
- let index = 0;
79
- index <= bytes.length - needle.length;
80
- index += 1
81
- ) {
82
- for (let offset = 0; offset < needle.length; offset += 1) {
83
- if (bytes[index + offset] !== needle[offset]) {
84
- continue outer;
85
- }
86
- }
87
- return true;
88
- }
89
-
90
- return false;
91
- }
92
-
93
50
  function isPasswordProtectedPdf(bytes: Uint8Array): boolean {
94
51
  if (!hasPrefix(bytes, PDF_SIGNATURE)) {
95
52
  return false;
@@ -113,14 +70,6 @@ function hasCompletePdfTrailer(bytes: Uint8Array): boolean {
113
70
  return startXrefIndex >= 0 && eofIndex > startXrefIndex;
114
71
  }
115
72
 
116
- function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
117
- return (
118
- hasPrefix(bytes, CFB_SIGNATURE) &&
119
- includesBytes(bytes, ENCRYPTION_INFO) &&
120
- includesBytes(bytes, ENCRYPTED_PACKAGE)
121
- );
122
- }
123
-
124
73
  function isPasswordProtected(input: ConvertInput): boolean {
125
74
  if (input.ext === ".pdf") {
126
75
  return isPasswordProtectedPdf(input.bytes);
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Byte checks shared by document adapters: signature prefixes and the
3
+ * encrypted-OOXML (CFB container) test. Dependency-free so an adapter can
4
+ * use them without loading another adapter's parser stack.
5
+ *
6
+ * @module src/converters/adapters/shared/ooxml-protection
7
+ */
8
+
9
+ const CFB_SIGNATURE = new Uint8Array([
10
+ 0xd0, 0xcf, 0x11, 0xe0, 0xa1, 0xb1, 0x1a, 0xe1,
11
+ ]);
12
+
13
+ export function utf16le(value: string): Uint8Array {
14
+ return new Uint8Array(Buffer.from(value, "utf16le"));
15
+ }
16
+
17
+ const ENCRYPTION_INFO = utf16le("EncryptionInfo");
18
+ const ENCRYPTED_PACKAGE = utf16le("EncryptedPackage");
19
+
20
+ export function hasPrefix(bytes: Uint8Array, prefix: Uint8Array): boolean {
21
+ if (bytes.length < prefix.length) {
22
+ return false;
23
+ }
24
+ for (let index = 0; index < prefix.length; index += 1) {
25
+ if (bytes[index] !== prefix[index]) {
26
+ return false;
27
+ }
28
+ }
29
+ return true;
30
+ }
31
+
32
+ export function includesBytes(bytes: Uint8Array, needle: Uint8Array): boolean {
33
+ if (needle.length === 0 || bytes.length < needle.length) {
34
+ return false;
35
+ }
36
+ outer: for (
37
+ let index = 0;
38
+ index <= bytes.length - needle.length;
39
+ index += 1
40
+ ) {
41
+ for (let offset = 0; offset < needle.length; offset += 1) {
42
+ if (bytes[index + offset] !== needle[offset]) {
43
+ continue outer;
44
+ }
45
+ }
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /** An encrypted (password-protected) OOXML workbook: a CFB container. */
52
+ export function isPasswordProtectedXlsx(bytes: Uint8Array): boolean {
53
+ return (
54
+ hasPrefix(bytes, CFB_SIGNATURE) &&
55
+ includesBytes(bytes, ENCRYPTION_INFO) &&
56
+ includesBytes(bytes, ENCRYPTED_PACKAGE)
57
+ );
58
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Excel (.xlsx) converter: SheetJS workbook -> Markdown tables, linear in
3
+ * cells (fn-198).
4
+ *
5
+ * Produces the Markdown markitdown-ts produced for workbooks (one `## sheet`
6
+ * section and GFM table per sheet with a range) but renders each table
7
+ * directly from the sheet data instead of through HTML, jsdom and turndown,
8
+ * whose cost grew with columns x rows^2 and about 20 KB per cell. Pivot
9
+ * cache parts are not user content and SheetJS does not read them, as
10
+ * before.
11
+ *
12
+ * @module src/converters/adapters/xlsx/adapter
13
+ */
14
+
15
+ import * as XLSX from "xlsx";
16
+
17
+ import type { Converter, ConvertInput, ConvertResult } from "../../types";
18
+
19
+ import { corruptError, permissionError, tooLargeError } from "../../errors";
20
+ import { ADAPTER_VERSIONS } from "../../versions";
21
+ import { isPasswordProtectedXlsx } from "../shared/ooxml-protection";
22
+ import { sheetToMarkdown } from "./sheet-markdown";
23
+
24
+ const CONVERTER_ID = "adapter/xlsx" as const;
25
+ const CONVERTER_VERSION = ADAPTER_VERSIONS.xlsx;
26
+ const XLSX_MIME =
27
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
28
+ const PASSWORD_ERROR_REGEX = /password(?:-protected)?|no password given/i;
29
+ /** Output under this length from a larger file is flagged as lossy. */
30
+ const SHORT_OUTPUT_CHARS = 10;
31
+ const SHORT_OUTPUT_MIN_BYTES = 1000;
32
+
33
+ /**
34
+ * Markdown for a parsed workbook: one section per sheet with a range,
35
+ * normalized as markitdown-ts normalizes every result (line endings, outer
36
+ * whitespace, at most one blank line).
37
+ */
38
+ export function workbookToMarkdown(workbook: XLSX.WorkBook): string {
39
+ let markdown = "";
40
+ for (const name of workbook.SheetNames) {
41
+ const sheet = workbook.Sheets[name];
42
+ if (sheet?.["!ref"]) {
43
+ markdown += `## ${name}\n${sheetToMarkdown(sheet)}\n\n`;
44
+ }
45
+ }
46
+ return markdown
47
+ .replace(/\r\n|\r|\n/g, "\n")
48
+ .trim()
49
+ .replace(/\n{3,}/g, "\n\n");
50
+ }
51
+
52
+ export const xlsxAdapter: Converter = {
53
+ id: CONVERTER_ID,
54
+ version: CONVERTER_VERSION,
55
+
56
+ canHandle(mime: string, ext: string): boolean {
57
+ return ext === ".xlsx" || mime === XLSX_MIME;
58
+ },
59
+
60
+ convert(input: ConvertInput): Promise<ConvertResult> {
61
+ return Promise.resolve(convertWorkbook(input));
62
+ },
63
+ };
64
+
65
+ function convertWorkbook(input: ConvertInput): ConvertResult {
66
+ if (input.bytes.length > input.limits.maxBytes) {
67
+ return { ok: false, error: tooLargeError(input, CONVERTER_ID) };
68
+ }
69
+ if (isPasswordProtectedXlsx(input.bytes)) {
70
+ return {
71
+ ok: false,
72
+ error: permissionError(
73
+ input,
74
+ CONVERTER_ID,
75
+ "File is password-protected",
76
+ undefined,
77
+ { protection: "xlsx" }
78
+ ),
79
+ };
80
+ }
81
+
82
+ let workbook: XLSX.WorkBook;
83
+ try {
84
+ // Dense sheets store rows as arrays: smaller than one object per cell.
85
+ workbook = XLSX.read(input.bytes, { type: "buffer", dense: true });
86
+ } catch (cause) {
87
+ const message = cause instanceof Error ? cause.message : String(cause);
88
+ return {
89
+ ok: false,
90
+ error: PASSWORD_ERROR_REGEX.test(message)
91
+ ? permissionError(
92
+ input,
93
+ CONVERTER_ID,
94
+ "File is password-protected",
95
+ cause,
96
+ { protection: "xlsx" }
97
+ )
98
+ : corruptError(
99
+ input,
100
+ CONVERTER_ID,
101
+ "Could not read the .xlsx workbook",
102
+ cause
103
+ ),
104
+ };
105
+ }
106
+
107
+ const markdown = workbookToMarkdown(workbook);
108
+ if (!markdown) {
109
+ return {
110
+ ok: false,
111
+ error: corruptError(input, CONVERTER_ID, "Empty conversion result"),
112
+ };
113
+ }
114
+ const lossy =
115
+ markdown.length < SHORT_OUTPUT_CHARS &&
116
+ input.bytes.length > SHORT_OUTPUT_MIN_BYTES;
117
+ return {
118
+ ok: true,
119
+ value: {
120
+ markdown,
121
+ title: workbook.Props?.Title || "Untitled",
122
+ meta: {
123
+ converterId: CONVERTER_ID,
124
+ converterVersion: CONVERTER_VERSION,
125
+ sourceMime: input.mime,
126
+ warnings: lossy
127
+ ? [{ code: "LOSSY", message: "Suspiciously short output" }]
128
+ : undefined,
129
+ },
130
+ },
131
+ };
132
+ }
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Worksheet to Markdown table, linear in cells (fn-198).
3
+ *
4
+ * Reproduces the Markdown the markitdown-ts chain produces for a worksheet
5
+ * (SheetJS `sheet_to_html` -> jsdom -> turndown + Joplin GFM tables) without
6
+ * building a DOM. That chain is O(columns x rows^2) and uses about 20 KB per
7
+ * cell: the GFM plugin's column-alignment scan re-reads `table.rows.length`
8
+ * through a jsdom proxy for every row of every column. Here each cell is
9
+ * rendered once from the sheet data:
10
+ *
11
+ * - cell text is chosen as `sheet_to_html` does (`h`, or escaped `w`),
12
+ * - plain text goes through turndown's whitespace collapsing and Markdown
13
+ * escaping, per line (a line break starts a new text node),
14
+ * - cells whose HTML carries markup (rich-text runs, hyperlinks) are
15
+ * converted by turndown itself with the same rules, one cell at a time,
16
+ * - rows, the empty heading row, `---` borders (SheetJS never sets an
17
+ * alignment), merges (`colspan` padding, covered cells skipped) and the
18
+ * single-cell-table case follow the GFM plugin.
19
+ *
20
+ * @module src/converters/adapters/xlsx/sheet-markdown
21
+ */
22
+
23
+ import type { CellObject, WorkSheet } from "xlsx";
24
+
25
+ // @ts-expect-error -- @joplin/turndown-plugin-gfm ships no types
26
+ import turndownPluginGfm from "@joplin/turndown-plugin-gfm";
27
+ // @ts-expect-error -- turndown ships no types
28
+ import TurndownService from "turndown";
29
+ import * as XLSX from "xlsx";
30
+
31
+ /** The part of turndown's API this module uses. */
32
+ interface Turndown {
33
+ turndown(html: string): string;
34
+ use(plugin: unknown): Turndown;
35
+ addRule(
36
+ key: string,
37
+ rule: {
38
+ filter: string[];
39
+ replacement: (
40
+ content: string,
41
+ node: { getAttribute(name: string): string | null; title: string }
42
+ ) => string;
43
+ }
44
+ ): Turndown;
45
+ }
46
+ const TurndownConstructor = TurndownService as new (options: {
47
+ headingStyle: "atx";
48
+ }) => Turndown;
49
+
50
+ /** SheetJS `escapehtml` (xlsx 0.20.3), used to recognise plain cell HTML. */
51
+ const HTML_ENCODINGS: Record<string, string> = {
52
+ '"': "&quot;",
53
+ "'": "&apos;",
54
+ ">": "&gt;",
55
+ "<": "&lt;",
56
+ "&": "&amp;",
57
+ };
58
+ const escapeHtml = (text: string): string =>
59
+ text
60
+ .replace(/[&<>'"]/g, (char) => HTML_ENCODINGS[char] ?? char)
61
+ .replace(/\n/g, "<br/>")
62
+ .replace(
63
+ // oxlint-disable-next-line no-control-regex -- SheetJS htmlcharegex: C0 controls become numeric references
64
+ /[\u0000-\u001f]/g,
65
+ (char) => `&#x${`000${char.charCodeAt(0).toString(16)}`.slice(-4)};`
66
+ );
67
+
68
+ /** turndown 7.2 `escapeMarkdown`, applied to each text node. */
69
+ const MARKDOWN_ESCAPES: Array<[RegExp, string]> = [
70
+ [/\\/g, "\\\\"],
71
+ [/\*/g, "\\*"],
72
+ [/^-/g, "\\-"],
73
+ [/^\+ /g, "\\+ "],
74
+ [/^(=+)/g, "\\$1"],
75
+ [/^(#{1,6}) /g, "\\$1 "],
76
+ [/`/g, "\\`"],
77
+ [/^~~~/g, "\\~~~"],
78
+ [/\[/g, "\\["],
79
+ [/\]/g, "\\]"],
80
+ [/^>/g, "\\>"],
81
+ [/_/g, "\\_"],
82
+ [/^(\d+)\. /g, "$1\\. "],
83
+ ];
84
+ /** Cheap pre-test: text that no escape rule can touch skips the chain. */
85
+ const MAY_NEED_ESCAPE = /[\\*`[\]_]|^[-+=#~>\d]/;
86
+
87
+ const escapeMarkdown = (text: string): string =>
88
+ MAY_NEED_ESCAPE.test(text)
89
+ ? MARKDOWN_ESCAPES.reduce(
90
+ (value, [pattern, replacement]) => value.replace(pattern, replacement),
91
+ text
92
+ )
93
+ : text;
94
+
95
+ /** turndown's `<br>` replacement with the default `br: " "` option. */
96
+ const LINE_BREAK = " \n";
97
+
98
+ /**
99
+ * Plain cell text as turndown renders it inside a table cell: each line is a
100
+ * text node whose whitespace runs collapse to one space, with the leading
101
+ * and trailing space dropped (cells and line breaks are block boundaries),
102
+ * escaped on its own; every line break renders, empty lines included.
103
+ */
104
+ const plainCellContent = (text: string): string => {
105
+ const lines: string[] = [];
106
+ for (const raw of text.split("\n")) {
107
+ // The HTML parser turns the escaped NUL (&#x0000;) into U+FFFD.
108
+ const line = raw
109
+ .replaceAll("\u0000", "\uFFFD")
110
+ .replace(/[ \r\n\t]+/g, " ")
111
+ .replace(/^ | $/g, "");
112
+ lines.push(line ? escapeMarkdown(line) : "");
113
+ }
114
+ return lines.join(LINE_BREAK);
115
+ };
116
+
117
+ let markupTurndown: Turndown | null = null;
118
+ const markupCache = new Map<string, string>();
119
+
120
+ /**
121
+ * Cell HTML with markup (rich-text runs, a hyperlink), converted by turndown
122
+ * with markitdown's rules. A paragraph gives the same block context as a
123
+ * table cell.
124
+ */
125
+ const markupCellContent = (html: string): string => {
126
+ const cached = markupCache.get(html);
127
+ if (cached !== undefined) return cached;
128
+ markupTurndown ??= createMarkitdownTurndown();
129
+ const content = markupTurndown.turndown(`<p>${html}</p>`);
130
+ markupCache.set(html, content);
131
+ return content;
132
+ };
133
+
134
+ /** markitdown-ts `CustomTurnDown` configuration (vendored dist). */
135
+ function createMarkitdownTurndown(): Turndown {
136
+ const service = new TurndownConstructor({ headingStyle: "atx" });
137
+ service.use((turndownPluginGfm as { gfm: unknown }).gfm);
138
+ service.addRule("anchor tags", {
139
+ filter: ["a"],
140
+ replacement(content, node) {
141
+ if (content === "") return "";
142
+ const prefix = content[0] === " " ? " " : "";
143
+ const suffix = content.at(-1) === " " ? " " : "";
144
+ const text = content.trim().replace(/\n\n.*/g, "");
145
+ if (text === "") return "";
146
+ const href = node.getAttribute("href");
147
+ let title = node.title;
148
+ if (href) {
149
+ try {
150
+ const parsed = new URL(href);
151
+ if (!["https:", "http:", "file:"].includes(parsed.protocol)) {
152
+ return `${prefix}${text}${suffix}`;
153
+ }
154
+ } catch {
155
+ if (!/^https?:|^file:/.test(href)) {
156
+ return `${prefix}[${text}](${href} "${title}")${suffix}`;
157
+ }
158
+ return `${prefix}${text}${suffix}`;
159
+ }
160
+ }
161
+ if (text.replace(/\\_/g, "_") === href && !title) return `<${href}>`;
162
+ if (!title && href) title = href;
163
+ const titlePart = title ? ` "${title}"` : "";
164
+ return `${prefix}[${text}](${href}${titlePart})${suffix}`;
165
+ },
166
+ });
167
+ return service;
168
+ }
169
+
170
+ /** Joplin GFM `cell()`: trim, `<br>` for newlines, escape pipes, pad. */
171
+ const formatCell = (content: string, first: boolean, colspan: number) => {
172
+ let value = content
173
+ .trim()
174
+ .replace(/\n\r/g, "<br>")
175
+ .replace(/\n/g, "<br>")
176
+ .replace(/\|+/g, "\\|");
177
+ while (value.length < 3) value += " ";
178
+ for (let extra = 1; extra < colspan; extra += 1) value += " | ";
179
+ return `${first ? "| " : " "}${value} |`;
180
+ };
181
+
182
+ /** SheetJS error text for non-finite numbers (`make_html_row`). */
183
+ const nonFiniteText = (value: number): string =>
184
+ Number.isNaN(value) ? "#NUM!" : "#DIV/0!";
185
+
186
+ /**
187
+ * Cell Markdown content (before `formatCell`), from the HTML `make_html_row`
188
+ * (xlsx 0.20.3) writes for it: `h` if present, else the escaped formatted
189
+ * text, wrapped in a link for an external hyperlink. HTML that is exactly
190
+ * the escaped text is rendered from the text; any other HTML goes through
191
+ * turndown.
192
+ */
193
+ const cellContent = (cell: CellObject | undefined): string => {
194
+ if (!cell || cell.v == null) return "";
195
+ let text: string;
196
+ let html: string;
197
+ if (cell.t === "n" && !Number.isFinite(cell.v as number)) {
198
+ // SheetJS swaps in an error cell, which has no `h` and no hyperlink.
199
+ text = nonFiniteText(cell.v as number);
200
+ return text ? plainCellContent(text) : "";
201
+ }
202
+ if (cell.h) {
203
+ html = cell.h;
204
+ text = typeof cell.v === "string" ? cell.v : "";
205
+ } else {
206
+ text =
207
+ cell.w ||
208
+ (XLSX.utils.format_cell(cell), cell.w as string | undefined) ||
209
+ "";
210
+ html = escapeHtml(text);
211
+ }
212
+ if (!html) return "";
213
+ const target = cell.l?.Target;
214
+ if (target && target.charAt(0) !== "#") {
215
+ return markupCellContent(`<a href="${escapeHtml(target)}">${html}</a>`);
216
+ }
217
+ return escapeHtml(text) === html
218
+ ? plainCellContent(text)
219
+ : markupCellContent(html);
220
+ };
221
+
222
+ interface MergeSpan {
223
+ rowspan: number;
224
+ colspan: number;
225
+ }
226
+
227
+ /**
228
+ * Merge lookup as `make_html_row` resolves it: the first merge (in sheet
229
+ * order) containing a cell decides; its top-left cell carries the span and
230
+ * the other covered cells are skipped.
231
+ */
232
+ const mergeLookup = (
233
+ merges: XLSX.Range[]
234
+ ): ((row: number, col: number) => MergeSpan | "covered" | undefined) => {
235
+ if (merges.length === 0) return () => undefined;
236
+ const byCell = new Map<string, MergeSpan | "covered">();
237
+ for (let index = merges.length - 1; index >= 0; index -= 1) {
238
+ const merge = merges[index];
239
+ if (!merge) continue;
240
+ for (let row = merge.s.r; row <= merge.e.r; row += 1) {
241
+ for (let col = merge.s.c; col <= merge.e.c; col += 1) {
242
+ byCell.set(
243
+ `${row}:${col}`,
244
+ row === merge.s.r && col === merge.s.c
245
+ ? {
246
+ rowspan: merge.e.r - merge.s.r + 1,
247
+ colspan: merge.e.c - merge.s.c + 1,
248
+ }
249
+ : "covered"
250
+ );
251
+ }
252
+ }
253
+ }
254
+ return (row, col) => byCell.get(`${row}:${col}`);
255
+ };
256
+
257
+ const readCell = (
258
+ sheet: WorkSheet,
259
+ row: number,
260
+ col: number
261
+ ): CellObject | undefined => {
262
+ const dense = (sheet as { "!data"?: CellObject[][] })["!data"];
263
+ if (dense) return dense[row]?.[col];
264
+ return sheet[XLSX.utils.encode_cell({ r: row, c: col })] as
265
+ | CellObject
266
+ | undefined;
267
+ };
268
+
269
+ /**
270
+ * Markdown for one worksheet with a `!ref`, trimmed as markitdown trims it.
271
+ * Output matches `turndown(sheet_to_html(sheet))` for the same sheet.
272
+ */
273
+ export function sheetToMarkdown(sheet: WorkSheet): string {
274
+ // Memoized markup is per sheet: the file processor reuses one worker across
275
+ // files, so a process-wide cache would retain every earlier workbook.
276
+ markupCache.clear();
277
+ const range = XLSX.utils.decode_range(sheet["!ref"] ?? "A1");
278
+ const mergeAt = mergeLookup(sheet["!merges"] ?? []);
279
+ const rows: string[] = [];
280
+ let columnCount = 0;
281
+ let onlyCell: string | null = null;
282
+ let firstRowCells = 0;
283
+ for (let row = range.s.r; row <= range.e.r; row += 1) {
284
+ const cells: string[] = [];
285
+ let cellCount = 0;
286
+ for (let col = range.s.c; col <= range.e.c; col += 1) {
287
+ const merge = mergeAt(row, col);
288
+ if (merge === "covered") continue;
289
+ const content = cellContent(readCell(sheet, row, col));
290
+ cells.push(formatCell(content, cellCount === 0, merge?.colspan ?? 1));
291
+ cellCount += 1;
292
+ onlyCell = content;
293
+ }
294
+ columnCount = Math.max(columnCount, cellCount);
295
+ if (row === range.s.r) firstRowCells = cellCount;
296
+ // A row whose cells are all covered by merges renders as an empty line,
297
+ // which the GFM plugin collapses away.
298
+ if (cells.length > 0) rows.push(cells.join(""));
299
+ }
300
+
301
+ // A one-row table with at most one cell is left as plain content.
302
+ if (range.e.r === range.s.r && firstRowCells <= 1) {
303
+ return (onlyCell ?? "").trim();
304
+ }
305
+
306
+ const secondLine = rows[1] ?? "";
307
+ const header =
308
+ columnCount && !/\| :?---/.test(secondLine)
309
+ ? `|${" |".repeat(columnCount)}\n|${" --- |".repeat(columnCount)}\n`
310
+ : "";
311
+ return `${header}${rows.join("\n")}`.trim();
312
+ }