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.
This commit is contained in:
Kodai Nakamura 2026-06-22 02:14:30 +09:00
parent a8afe1d847
commit 73014eb012
5 changed files with 336 additions and 96 deletions

View file

@ -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.

View file

@ -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]]")
})
})

View file

@ -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<string, string> }).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")

100
src/formatting-run.ts Normal file
View file

@ -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<string, CandidateData>
}
export interface FormattingRunOptions {
content: string
filePath: string
contentStart?: number
frontmatter?: Record<string, unknown>
settings: AutomaticLinkerSettings
baseDir?: string
candidateIndex?: CandidateIndex
urlTitleMap?: Map<string, string>
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<FormattingRunOptions, "content"> & { 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 })
}

View file

@ -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, unknown>,
): 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,
})