From 73014eb0123fb5ca6c845930ecb31f8ce80a3bec Mon Sep 17 00:00:00 2001 From: Kodai Nakamura Date: Mon, 22 Jun 2026 02:14:30 +0900 Subject: [PATCH] refactor: extract pure formatting run Why: - main.ts mixed Obsidian adapter work with pure transformation sequencing, which made formatting behavior require plugin mocks to test. - A pure formatting run increases locality for URL formatting, URL title replacement, and link replacement order. What: - Add a formatting-run module with document and body formatting entry points. - Route file and selection formatting through the new module. - Move pure frontmatter URL-title coverage out of main adapter tests. --- .superpowers/sdd/task-2-report.md | 85 +++++++++++++ src/__tests__/formatting-run.test.ts | 113 ++++++++++++++++++ .../main-url-title-frontmatter.test.ts | 25 ---- src/formatting-run.ts | 100 ++++++++++++++++ src/main.ts | 109 ++++++----------- 5 files changed, 336 insertions(+), 96 deletions(-) create mode 100644 .superpowers/sdd/task-2-report.md create mode 100644 src/__tests__/formatting-run.test.ts create mode 100644 src/formatting-run.ts diff --git a/.superpowers/sdd/task-2-report.md b/.superpowers/sdd/task-2-report.md new file mode 100644 index 0000000..6fbca3a --- /dev/null +++ b/.superpowers/sdd/task-2-report.md @@ -0,0 +1,85 @@ +# Task 2 Report: Formatting Run Module + +## Scope + +Implemented Task 2 only: + +- Added `src/formatting-run.ts` +- Added `src/__tests__/formatting-run.test.ts` +- Updated `src/main.ts` to route document and selection formatting through the new module +- Reduced duplicated adapter-level URL-title/frontmatter coverage in `src/__tests__/main-url-title-frontmatter.test.ts` + +Preserved Task 1 behavior: + +- `resolveAmbiguities()` still receives the normalized current file path +- AI command still renders via `replaceLinks(...)` +- AI command now uses `toReplaceLinksSettings(this.settings, baseDir)` +- Link replacement still normalizes source file paths by removing a trailing `.md` + +## TDD Evidence + +### RED + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Result: + +- Failed as expected +- Error: `Cannot find module '../formatting-run'` + +### GREEN + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts +``` + +Result: + +- Passed: `4` tests in `src/__tests__/formatting-run.test.ts` + +## Focused Verification + +Command: + +```bash +npx vitest run src/__tests__/formatting-run.test.ts src/__tests__/main-url-title-frontmatter.test.ts src/__tests__/main-link-generator.test.ts +``` + +Result: + +- Passed: `3` test files, `6` tests + +## Full Stage Verification + +Commands: + +```bash +npm run test -- --reporter=dot +npm run tsc +npm run lint +``` + +Results: + +- `npm run test -- --reporter=dot`: passed `39` test files, `334` tests +- `npm run tsc`: exit `0` +- `npm run lint`: exit `0` + +## Implementation Notes + +- `formatMarkdownDocument(...)` owns frontmatter splitting and delegates body formatting +- `formatMarkdownBody(...)` sequences GitHub/Jira/Linear URL formatting, URL-title replacement, then link replacement +- `toReplaceLinksSettings(...)` projects only the settings required by `replaceLinks(...)` and carries `baseDir` +- `modifyLinks(...)` now uses the pure formatter for both indexed and non-indexed runs +- `mofifyLinksSelection(...)` now uses `formatMarkdownBody(...)` +- Adapter-only URL-title fetch opt-out coverage remains in `main-url-title-frontmatter.test.ts` + +## Concerns + +None. diff --git a/src/__tests__/formatting-run.test.ts b/src/__tests__/formatting-run.test.ts new file mode 100644 index 0000000..323f1a6 --- /dev/null +++ b/src/__tests__/formatting-run.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest" +import { + formatMarkdownBody, + formatMarkdownDocument, + toReplaceLinksSettings, +} from "../formatting-run" +import { buildCandidateTrieForTest } from "../replace-links/__tests__/test-helpers" +import { DEFAULT_SETTINGS } from "../settings/settings-info" + +describe("toReplaceLinksSettings", () => { + it("projects only replacement settings and applies baseDir", () => { + expect(toReplaceLinksSettings({ + ...DEFAULT_SETTINGS, + proximityBasedLinking: false, + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }, "pages")).toEqual({ + proximityBasedLinking: false, + baseDir: "pages", + ignoreDateFormats: false, + ignoreCase: false, + matchSentenceCase: false, + preventSelfLinking: true, + removeAliasInDirs: ["archive"], + ignoreHeadings: true, + ignoreMarkdownTables: true, + }) + }) +}) + +describe("formatMarkdownDocument", () => { + it("preserves frontmatter and respects URL title opt-out", () => { + const result = formatMarkdownDocument({ + content: "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + filePath: "current-file.md", + frontmatter: { "automatic-linker-disable-url-title": true }, + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe( + "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", + ) + }) + + it("runs URL formatting, URL titles, and link replacement in order", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownDocument({ + content: "Read TypeScript at https://example.com", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: true, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + urlTitleMap: new Map([["https://example.com", "Example Title"]]), + }) + + expect(result).toBe("Read [[notes/TypeScript|TypeScript]] at [Example Title](https://example.com)") + }) +}) + +describe("formatMarkdownBody", () => { + it("formats selected body text without frontmatter splitting", () => { + const { candidateMap, trie } = buildCandidateTrieForTest({ + files: [{ path: "notes/TypeScript" }], + settings: { + scoped: false, + baseDir: undefined, + ignoreCase: true, + }, + }) + + const result = formatMarkdownBody({ + body: "TypeScript", + filePath: "current-file.md", + settings: { + ...DEFAULT_SETTINGS, + formatGitHubURLs: false, + formatJiraURLs: false, + formatLinearURLs: false, + replaceUrlWithTitle: false, + ignoreCase: true, + }, + candidateIndex: { candidateMap, trie }, + }) + + expect(result).toBe("[[notes/TypeScript|TypeScript]]") + }) +}) diff --git a/src/__tests__/main-url-title-frontmatter.test.ts b/src/__tests__/main-url-title-frontmatter.test.ts index 0a8b7e4..3804c00 100644 --- a/src/__tests__/main-url-title-frontmatter.test.ts +++ b/src/__tests__/main-url-title-frontmatter.test.ts @@ -46,31 +46,6 @@ describe("AutomaticLinkerPlugin URL title frontmatter opt-out", () => { requestMock.mockReset() }) - it("keeps raw URLs when URL title replacement is disabled in frontmatter", async () => { - const { default: AutomaticLinkerPlugin } = await import("../main") - const plugin = new AutomaticLinkerPlugin({} as never, {} as never) - plugin.settings = { - ...DEFAULT_SETTINGS, - formatGitHubURLs: false, - formatJiraURLs: false, - formatLinearURLs: false, - replaceUrlWithTitle: true, - } - ;(plugin as unknown as { urlTitleMap: Map }).urlTitleMap = new Map([ - ["https://example.com", "Example Title"], - ]) - - const result = plugin.modifyLinks( - "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", - "current-file.md", - { "automatic-linker-disable-url-title": true }, - ) - - expect(result).toBe( - "---\nautomatic-linker-disable-url-title: true\n---\nhttps://example.com", - ) - }) - it("does not fetch URL titles when disabled in active file frontmatter", async () => { const { default: AutomaticLinkerPlugin } = await import("../main") const activeFile = new MockTFile("current-file.md") diff --git a/src/formatting-run.ts b/src/formatting-run.ts new file mode 100644 index 0000000..e174277 --- /dev/null +++ b/src/formatting-run.ts @@ -0,0 +1,100 @@ +import { isUrlTitleReplacementOff } from "./frontmatter-utils" +import { + LinkGenerator, + ReplaceLinksSettings, + replaceLinks, +} from "./replace-links/replace-links" +import { replaceUrlWithTitle } from "./replace-url-with-title" +import { formatGitHubURL } from "./replace-urls/github" +import { formatJiraURL } from "./replace-urls/jira" +import { formatLinearURL } from "./replace-urls/linear" +import { replaceURLs } from "./replace-urls/replace-urls" +import { AutomaticLinkerSettings } from "./settings/settings-info" +import { CandidateData, TrieNode } from "./trie" + +export interface CandidateIndex { + trie: TrieNode + candidateMap: Map +} + +export interface FormattingRunOptions { + content: string + filePath: string + contentStart?: number + frontmatter?: Record + settings: AutomaticLinkerSettings + baseDir?: string + candidateIndex?: CandidateIndex + urlTitleMap?: Map + linkGenerator?: LinkGenerator +} + +export const toReplaceLinksSettings = ( + settings: AutomaticLinkerSettings, + baseDir?: string, +): ReplaceLinksSettings => ({ + proximityBasedLinking: settings.proximityBasedLinking, + baseDir, + ignoreDateFormats: settings.ignoreDateFormats, + ignoreCase: settings.ignoreCase, + matchSentenceCase: settings.matchSentenceCase, + preventSelfLinking: settings.preventSelfLinking, + removeAliasInDirs: settings.removeAliasInDirs, + ignoreHeadings: settings.ignoreHeadings, + ignoreMarkdownTables: settings.ignoreMarkdownTables, +}) + +export const formatMarkdownBody = ({ + body, + filePath, + frontmatter, + settings, + baseDir, + candidateIndex, + urlTitleMap = new Map(), + linkGenerator, +}: Omit & { body: string }): string => { + let updatedBody = body + + if (settings.formatGitHubURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatGitHubURL) + } + if (settings.formatJiraURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatJiraURL) + } + if (settings.formatLinearURLs) { + updatedBody = replaceURLs(updatedBody, settings, formatLinearURL) + } + if (settings.replaceUrlWithTitle && !isUrlTitleReplacementOff(frontmatter)) { + updatedBody = replaceUrlWithTitle({ body: updatedBody, urlTitleMap }) + } + if (candidateIndex) { + updatedBody = replaceLinks({ + body: updatedBody, + linkResolverContext: { + filePath: filePath.replace(/\.md$/, ""), + trie: candidateIndex.trie, + candidateMap: candidateIndex.candidateMap, + }, + settings: toReplaceLinksSettings(settings, baseDir), + linkGenerator, + }) + } + + return updatedBody +} + +const inferContentStart = (content: string): number => { + const frontmatter = content.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/) + return frontmatter?.[0].length ?? 0 +} + +export const formatMarkdownDocument = ({ + content, + contentStart = inferContentStart(content), + ...options +}: FormattingRunOptions): string => { + const frontmatterText = content.slice(0, contentStart) + const body = content.slice(contentStart) + return frontmatterText + formatMarkdownBody({ ...options, body }) +} diff --git a/src/main.ts b/src/main.ts index cf806d6..c26e6dc 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,11 @@ import { TFile, } from "obsidian" import { excludeLinks } from "./exclude-links" +import { + formatMarkdownBody, + formatMarkdownDocument, + toReplaceLinksSettings, +} from "./formatting-run" import { isLinkingOff, isLinkingExcluded, @@ -26,13 +31,8 @@ import { LinkGeneratorParams, replaceLinks, } from "./replace-links/replace-links" -import { replaceUrlWithTitle } from "./replace-url-with-title" import { getTitleFromHtml } from "./replace-url-with-title/utils/get-title-from-html" import { listupAllUrls } from "./replace-url-with-title/utils/list-up-all-urls" -import { formatGitHubURL } from "./replace-urls/github" -import { formatJiraURL } from "./replace-urls/jira" -import { formatLinearURL } from "./replace-urls/linear" -import { replaceURLs } from "./replace-urls/replace-urls" import { AutomaticLinkerPluginSettingsTab } from "./settings/settings" import { AutomaticLinkerSettings, @@ -98,35 +98,15 @@ export default class AutomaticLinkerPlugin extends Plugin { filePath: string, frontmatter?: Record, ): string { - if (this.settings.formatGitHubURLs) { - fileContent = replaceURLs(fileContent, this.settings, formatGitHubURL) - } - - if (this.settings.formatJiraURLs) { - fileContent = replaceURLs(fileContent, this.settings, formatJiraURL) - } - - if (this.settings.formatLinearURLs) { - fileContent = replaceURLs( - fileContent, - this.settings, - formatLinearURL, - ) - } - - if ( - this.settings.replaceUrlWithTitle - && !isUrlTitleReplacementOff(frontmatter) - ) { - const { contentStart } = getFrontMatterInfo(fileContent) - const frontmatterText = fileContent.slice(0, contentStart) - const body = fileContent.slice(contentStart) - const updatedBody = replaceUrlWithTitle({ body, urlTitleMap: this.urlTitleMap }) - fileContent = frontmatterText + updatedBody - } - if (!this.trie || !this.candidateMap) { - return fileContent + return formatMarkdownDocument({ + content: fileContent, + filePath, + contentStart: getFrontMatterInfo(fileContent).contentStart, + frontmatter, + settings: this.settings, + urlTitleMap: this.urlTitleMap, + }) } if (this.settings.debug) { @@ -136,31 +116,21 @@ export default class AutomaticLinkerPlugin extends Plugin { new Notice(`Automatic Linker: ${new Date().toISOString()} modifyLinks started.`) } - const { contentStart } = getFrontMatterInfo(fileContent) - const frontmatterText = fileContent.slice(0, contentStart) - const linkGenerator = this.createLinkGenerator(filePath) const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined - const updatedBody = replaceLinks({ - body: fileContent.slice(contentStart), - linkResolverContext: { - filePath: filePath.replace(/\.md$/, ""), - trie: this.trie, - candidateMap: this.candidateMap, - }, - settings: { - proximityBasedLinking: this.settings.proximityBasedLinking, - baseDir, - ignoreDateFormats: this.settings.ignoreDateFormats, - ignoreCase: this.settings.ignoreCase, - matchSentenceCase: this.settings.matchSentenceCase, - preventSelfLinking: this.settings.preventSelfLinking, - removeAliasInDirs: this.settings.removeAliasInDirs, - ignoreHeadings: this.settings.ignoreHeadings, - ignoreMarkdownTables: this.settings.ignoreMarkdownTables, - }, - linkGenerator, + const candidateIndex = this.trie && this.candidateMap + ? { trie: this.trie, candidateMap: this.candidateMap } + : undefined + fileContent = formatMarkdownDocument({ + content: fileContent, + filePath, + contentStart: getFrontMatterInfo(fileContent).contentStart, + frontmatter, + settings: this.settings, + baseDir, + candidateIndex, + urlTitleMap: this.urlTitleMap, + linkGenerator: candidateIndex ? this.createLinkGenerator(filePath) : undefined, }) - fileContent = frontmatterText + updatedBody if (this.settings.debug) { console.log(new Date().toISOString(), "modifyLinks finished") @@ -263,24 +233,15 @@ export default class AutomaticLinkerPlugin extends Plugin { const linkGenerator = this.createLinkGenerator(activeFile.path) const baseDir = this.settings.respectNewFileFolderPath ? this.app.vault.getConfig("newFileFolderPath") : undefined - const updatedText = replaceLinks({ + const updatedText = formatMarkdownBody({ body: selectedText, - linkResolverContext: { - filePath: activeFile.path.replace(/\.md$/, ""), + filePath: activeFile.path, + settings: this.settings, + baseDir, + candidateIndex: { trie: this.trie, candidateMap: this.candidateMap, }, - settings: { - proximityBasedLinking: this.settings.proximityBasedLinking, - baseDir, - ignoreDateFormats: this.settings.ignoreDateFormats, - ignoreCase: this.settings.ignoreCase, - matchSentenceCase: this.settings.matchSentenceCase, - preventSelfLinking: this.settings.preventSelfLinking, - removeAliasInDirs: this.settings.removeAliasInDirs, - ignoreHeadings: this.settings.ignoreHeadings, - ignoreMarkdownTables: this.settings.ignoreMarkdownTables, - }, linkGenerator, }) cm.replaceSelection(updatedText) @@ -517,6 +478,9 @@ export default class AutomaticLinkerPlugin extends Plugin { const { contentStart } = getFrontMatterInfo(fileContent) const body = fileContent.slice(contentStart) const normalizedActiveFilePath = activeFile.path.replace(/\.md$/, "") + const baseDir = this.settings.respectNewFileFolderPath + ? this.app.vault.getConfig("newFileFolderPath") + : undefined if (!this.candidateMap || !this.trie) { this.refreshFileDataAndTrie() @@ -542,7 +506,10 @@ export default class AutomaticLinkerPlugin extends Plugin { trie: this.trie, candidateMap: this.candidateMap, }, - settings: this.settings, + settings: toReplaceLinksSettings( + this.settings, + baseDir, + ), resolvedAmbiguities: resolvedAmbiguitiesResult, })