mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
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:
parent
a8afe1d847
commit
73014eb012
5 changed files with 336 additions and 96 deletions
85
.superpowers/sdd/task-2-report.md
Normal file
85
.superpowers/sdd/task-2-report.md
Normal 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.
|
||||
113
src/__tests__/formatting-run.test.ts
Normal file
113
src/__tests__/formatting-run.test.ts
Normal 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]]")
|
||||
})
|
||||
})
|
||||
|
|
@ -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
100
src/formatting-run.ts
Normal 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 })
|
||||
}
|
||||
109
src/main.ts
109
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, 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,
|
||||
})
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue