diff --git a/package-lock.json b/package-lock.json index bcf4364f..29d75505 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,9 +11,12 @@ "dependencies": { "@tanstack/query-core": "^5.101.2", "defuddle": "^0.19.1", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-markdown": "^2.1.2", "micromark": "^4.0.2", "obsidian-daily-notes-interface": "^0.9.5", - "preact": "^10.29.7" + "preact": "^10.29.7", + "unist-util-visit": "^5.1.0" }, "devDependencies": { "@biomejs/biome": "^2.5.3", @@ -21,6 +24,7 @@ "@commitlint/config-conventional": "^21.2.0", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", + "@types/mdast": "^4.0.4", "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", @@ -3894,6 +3898,15 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@types/ms": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", @@ -3919,6 +3932,12 @@ "@types/estree": "*" } }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.64.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz", @@ -8608,6 +8627,16 @@ "dev": true, "license": "MIT" }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -8689,6 +8718,78 @@ "@xmldom/xmldom": "^0.9.10" } }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/mdn-data": { "version": "2.27.1", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", @@ -8971,6 +9072,28 @@ "micromark-util-symbol": "^2.0.0" } }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, "node_modules/micromark-util-encode": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", @@ -11071,6 +11194,61 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -11681,6 +11859,16 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } } } } diff --git a/package.json b/package.json index 93b5c5e4..585f80e6 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "@commitlint/config-conventional": "^21.2.0", "@stryker-mutator/core": "^9.6.1", "@stryker-mutator/vitest-runner": "^9.6.1", + "@types/mdast": "^4.0.4", "@types/node": "^26.1.1", "@vitest/coverage-v8": "^4.1.10", "concurrently": "^10.0.3", @@ -65,8 +66,11 @@ "dependencies": { "@tanstack/query-core": "^5.101.2", "defuddle": "^0.19.1", + "mdast-util-from-markdown": "^2.0.3", + "mdast-util-to-markdown": "^2.1.2", "micromark": "^4.0.2", "obsidian-daily-notes-interface": "^0.9.5", - "preact": "^10.29.7" + "preact": "^10.29.7", + "unist-util-visit": "^5.1.0" } } diff --git a/src/domain/threads/archive-markdown.ts b/src/domain/threads/archive-markdown.ts index 49a20f9a..841eea49 100644 --- a/src/domain/threads/archive-markdown.ts +++ b/src/domain/threads/archive-markdown.ts @@ -1,15 +1,19 @@ +import type { Link, Nodes } from "mdast"; +import { fromMarkdown } from "mdast-util-from-markdown"; +import { toMarkdown } from "mdast-util-to-markdown"; +import { visit } from "unist-util-visit"; + import { yamlFrontmatterInlineList, yamlFrontmatterString } from "../markdown/frontmatter"; -import { isExternalFileHref, parseFileHref } from "../vault/file-hrefs"; +import { parseFileHref } from "../vault/file-hrefs"; import { isFilesystemAbsolutePath, isVaultConfigPath, normalizeFilePath, vaultRelativePath } from "../vault/paths"; import type { Thread } from "./model"; import { threadArchiveTitle } from "./title"; import type { ThreadTranscriptEntry } from "./transcript"; -interface ParsedMarkdownLink { - raw: string; - label: string; - href: string; +interface MarkdownSourceReplacement { + start: number; end: number; + value: string; } export interface ArchiveExportSettings { @@ -31,49 +35,28 @@ export function archivedThreadMarkdown( ): string { const title = threadArchiveTitle(thread); const tags = normalizedArchiveTags(settings?.archiveExportTags ?? ""); - const lines = [ + const frontmatter = [ "---", `title: ${yamlFrontmatterString(title)}`, `thread_id: ${yamlFrontmatterString(thread.id)}`, `created: ${yamlFrontmatterString(formatDate(exportedAt))}`, ...frontmatterTagsLines(tags), "---", - "", - `# ${title}`, - "", - ...transcriptMarkdownLines(thread.transcriptEntries), - ]; - const markdown = `${trimTrailingBlankLines(lines).join("\n")}\n`; - return settings?.vaultPath ? normalizeExportedMarkdownLinks(markdown, settings.vaultPath, settings.vaultConfigDir) : markdown; + ].join("\n"); + const body = `${trimTrailingBlankLines([`# ${title}`, "", ...transcriptMarkdownLines(thread.transcriptEntries)]).join("\n")}\n`; + const normalizedBody = settings?.vaultPath ? normalizeExportedMarkdownLinks(body, settings.vaultPath, settings.vaultConfigDir) : body; + return `${frontmatter}\n\n${normalizedBody}`; } function normalizeExportedMarkdownLinks(markdown: string, vaultPath: string, vaultConfigDir: string | null | undefined): string { - const lines = markdown.split("\n"); - let inFence = false; - return lines - .map((line) => { - if (line.trimStart().startsWith("```")) { - inFence = !inFence; - return line; - } - return inFence ? line : normalizeExportedMarkdownLinksInLine(line, vaultPath, vaultConfigDir); - }) - .join("\n"); -} - -function normalizeExportedMarkdownLinksInLine(line: string, vaultPath: string, vaultConfigDir: string | null | undefined): string { - let output = ""; - let index = 0; - while (index < line.length) { - const parsed = parseMarkdownLinkAt(line, index); - if (!parsed || isInsideInlineCode(line, index)) { - output += line[index] ?? ""; - index += 1; - continue; - } - - output += normalizedExportedMarkdownLink(parsed, vaultPath, vaultConfigDir); - index = parsed.end; + const replacements: MarkdownSourceReplacement[] = []; + visit(fromMarkdown(markdown), "link", (link) => { + const replacement = normalizedExportedMarkdownLink(link, vaultPath, vaultConfigDir); + if (replacement) replacements.push(replacement); + }); + let output = markdown; + for (const replacement of replacements.sort((left, right) => right.start - left.start)) { + output = `${output.slice(0, replacement.start)}${replacement.value}${output.slice(replacement.end)}`; } return output; } @@ -163,67 +146,45 @@ function stripMatchingQuotes(value: string): string { return (first === `"` || first === `'`) && first === last ? value.slice(1, -1) : value; } -function unwrappedMarkdownHref(value: string): string { - if (value.startsWith("<") && value.endsWith(">")) return value.slice(1, -1); - return value; -} +function normalizedExportedMarkdownLink( + link: Link, + vaultPath: string, + vaultConfigDir: string | null | undefined, +): MarkdownSourceReplacement | null { + const start = link.position?.start.offset; + const end = link.position?.end.offset; + if (start === undefined || end === undefined || !link.url) return null; -function parseMarkdownLinkAt(line: string, start: number): ParsedMarkdownLink | null { - if (line[start] !== "[" || line[start - 1] === "!") return null; + const parsed = parseFileHref(link.url); + if (!parsed) return null; + const vaultRelative = vaultRelativePath(vaultPath, parsed.path); + if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) { + return { + start, + end, + value: markdownNodeSource({ ...link, url: `${vaultRelative}${parsed.subpath}` }), + }; + } - const labelEnd = line.indexOf("]", start + 1); - if (labelEnd === -1 || line[labelEnd + 1] !== "(") return null; - - const hrefStart = labelEnd + 2; - const hrefEnd = line[hrefStart] === "<" ? line.indexOf(">)", hrefStart + 1) : line.indexOf(")", hrefStart); - if (hrefEnd === -1) return null; - - const end = line[hrefStart] === "<" ? hrefEnd + 2 : hrefEnd + 1; + if (!isFilesystemAbsolutePath(normalizeFilePath(parsed.path))) return null; return { - raw: line.slice(start, end), - label: line.slice(start + 1, labelEnd), - href: line.slice(hrefStart, hrefEnd + (line[hrefStart] === "<" ? 1 : 0)), + start, end, + value: markdownNodeSource({ + type: "paragraph", + children: [...link.children, { type: "text", value: " (" }, { type: "inlineCode", value: link.url }, { type: "text", value: ")" }], + }), }; } -function normalizedExportedMarkdownLink(link: ParsedMarkdownLink, vaultPath: string, vaultConfigDir: string | null | undefined): string { - const href = unwrappedMarkdownHref(link.href.trim()); - if (!href || isExternalFileHref(href)) return link.raw; - - const parsed = parseFileHref(href); - if (!parsed) return link.raw; - const vaultRelative = vaultRelativePath(vaultPath, parsed.path); - if (vaultRelative && !archiveExportShouldKeepAbsolute(vaultRelative, vaultConfigDir)) { - return `[${link.label}](${markdownHref(`${vaultRelative}${parsed.subpath}`)})`; - } - - return isFilesystemAbsolutePath(normalizeFilePath(parsed.path)) ? `${link.label} (${markdownCodeSpan(href)})` : link.raw; +function markdownNodeSource(node: Nodes): string { + return toMarkdown(node, { unsafe: [{ character: "|", inConstruct: ["label", "phrasing"] }] }).trimEnd(); } function archiveExportShouldKeepAbsolute(vaultRelativePath: string, vaultConfigDir: string | null | undefined): boolean { return typeof vaultConfigDir === "string" && vaultConfigDir.length > 0 && isVaultConfigPath(vaultRelativePath, vaultConfigDir); } -function markdownHref(value: string): string { - return /[\s()]/.test(value) ? `<${value}>` : value; -} - -function markdownCodeSpan(value: string): string { - let fenceLength = 1; - for (const match of value.matchAll(/`+/g)) fenceLength = Math.max(fenceLength, match[0].length + 1); - const fence = "`".repeat(fenceLength); - return fenceLength === 1 ? `${fence}${value}${fence}` : `${fence} ${value} ${fence}`; -} - -function isInsideInlineCode(line: string, offset: number): boolean { - let inCode = false; - for (let index = 0; index < offset; index += 1) { - if (line[index] === "`") inCode = !inCode; - } - return inCode; -} - function formatDate(date: Date): string { return `${String(date.getFullYear())}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`; } diff --git a/tests/domain/threads/archive-markdown.test.ts b/tests/domain/threads/archive-markdown.test.ts index 42072e72..a7490712 100644 --- a/tests/domain/threads/archive-markdown.test.ts +++ b/tests/domain/threads/archive-markdown.test.ts @@ -187,6 +187,8 @@ describe("thread archive export", () => { [ "[Vault note]()", "[Vault note with parens]()", + "[Vault note with bare parens](/Users/showhey/Vault/topics/My_(Note).md)", + '[**Titled vault note**](/Users/showhey/Vault/topics/Titled.md "Reference")', "[Vault source line](/Users/showhey/Vault/src/main.ts:12:4#L12)", "[Vault config](/Users/showhey/Vault/vault-config/plugins/codex-panel/main.js)", "[External file](/Users/showhey/Repos/project/README.md)", @@ -195,9 +197,13 @@ describe("thread archive export", () => { "[Website](https://example.com/docs)", "![Image](/Users/showhey/Repos/project/image.png)", "`[Code link](/Users/showhey/Repos/project/README.md)`", + "``[Double-fenced code link](/Users/showhey/Repos/project/README.md)``", "```", "[Code block link](/Users/showhey/Repos/project/README.md)", "```", + "~~~", + "[Tilde code block link](/Users/showhey/Repos/project/README.md)", + "~~~", ].join("\n"), 1, ), @@ -211,17 +217,23 @@ describe("thread archive export", () => { [ "[Vault note]()", "[Vault note with parens]()", + "[Vault note with bare parens](topics/My_\\(Note\\).md)", + '[**Titled vault note**](topics/Titled.md "Reference")', "[Vault source line](src/main.ts#L12)", "Vault config (`/Users/showhey/Vault/vault-config/plugins/codex-panel/main.js`)", "External file (`/Users/showhey/Repos/project/README.md`)", - "External file with backticks (``` /Users/showhey/Repos/project/a\\`b``c.md ```)", + "External file with backticks (```/Users/showhey/Repos/project/a`b``c.md```)", "[Relative](topics/Other.md)", "[Website](https://example.com/docs)", "![Image](/Users/showhey/Repos/project/image.png)", "`[Code link](/Users/showhey/Repos/project/README.md)`", + "``[Double-fenced code link](/Users/showhey/Repos/project/README.md)``", "```", "[Code block link](/Users/showhey/Repos/project/README.md)", "```", + "~~~", + "[Tilde code block link](/Users/showhey/Repos/project/README.md)", + "~~~", ].join("\n"), ); }); @@ -244,6 +256,45 @@ describe("thread archive export", () => { expect(output).toContain("[Vault](topics/Alpha.md)"); expect(output).toContain("External (`/Users/showhey/Repos/project/README.md`)"); }); + + it("preserves Obsidian syntax outside normalized links", () => { + const obsidianSyntax = [ + "[[Wiki note]]", + "![[image.png]]", + "> [!note] Callout", + "> body", + "#tag/path", + "==highlight==", + "%% comment %%", + "- [x] task", + ].join("\n"); + const output = exportedMarkdown( + thread({ transcriptEntries: [transcriptEntry("assistant", `${obsidianSyntax}\n[Vault](/Users/showhey/Vault/Note.md)`, 1)] }), + new Date(2026, 4, 18), + { vaultPath: "/Users/showhey/Vault" }, + ); + + expect(output).toContain(obsidianSyntax); + expect(output).toContain("[Vault](Note.md)"); + }); + + it("preserves escaped pipes in table link labels", () => { + const output = exportedMarkdown( + thread({ + transcriptEntries: [ + transcriptEntry( + "assistant", + "| Vault | External |\n| --- | --- |\n| [A\\|B](/Users/showhey/Vault/Note.md) | [C\\|D](/Outside/Note.md) |", + 1, + ), + ], + }), + new Date(2026, 4, 18), + { vaultPath: "/Users/showhey/Vault" }, + ); + + expect(output).toContain("| [A\\|B](Note.md) | C\\|D (`/Outside/Note.md`) |"); + }); }); function exportedMarkdown(