mirror of
https://github.com/kdnk/obsidian-automatic-linker.git
synced 2026-07-22 05:37:46 +00:00
refactor(markdown): centralize protected segment handling
Why: - Link replacement and URL title code each carried their own Markdown context checks, which made protected text behavior hard to keep consistent. - A shared Markdown segment module improves locality for prose-only transformations. - The refactor needed to preserve existing wikilink correction and table behavior without bringing back placeholder collisions. What: - Add a pure Markdown segment module with prose mapping and focused tests. - Route link replacement and URL title flows through shared protected segment handling. - Preserve existing-link correction, heading/callout/table protection, and add fast paths to keep performance within the existing thresholds.
This commit is contained in:
parent
f217ee9dad
commit
9209356ee0
5 changed files with 503 additions and 330 deletions
61
src/__tests__/markdown-segments.test.ts
Normal file
61
src/__tests__/markdown-segments.test.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import { describe, expect, it } from "vitest"
|
||||
import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
|
||||
|
||||
describe("segmentMarkdown", () => {
|
||||
it("round-trips prose and protected inline code", () => {
|
||||
const text = "Use `TypeScript` with TypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "Use " },
|
||||
{ kind: "protected", protectedKind: "inline-code", text: "`TypeScript`" },
|
||||
{ kind: "prose", protectedKind: undefined, text: " with TypeScript" },
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
|
||||
it("protects fenced code blocks including unclosed blocks", () => {
|
||||
const text = "before\n```ts\nTypeScript"
|
||||
const segments = segmentMarkdown(text)
|
||||
|
||||
expect(segments.map(segment => ({
|
||||
kind: segment.kind,
|
||||
protectedKind: segment.protectedKind,
|
||||
text: segment.text,
|
||||
}))).toEqual([
|
||||
{ kind: "prose", protectedKind: undefined, text: "before\n" },
|
||||
{ kind: "protected", protectedKind: "fenced-code", text: "```ts\nTypeScript" },
|
||||
])
|
||||
})
|
||||
|
||||
it("protects headings, tables, and callouts when requested", () => {
|
||||
const text = "# TypeScript\n| TypeScript |\n> [!note]\n> TypeScript\nTypeScript"
|
||||
const segments = segmentMarkdown(text, {
|
||||
protectHeadings: true,
|
||||
protectTableRows: true,
|
||||
protectCallouts: true,
|
||||
})
|
||||
|
||||
expect(segments.filter(segment => segment.kind === "protected").map(segment => segment.protectedKind)).toEqual([
|
||||
"heading",
|
||||
"table-row",
|
||||
"callout",
|
||||
])
|
||||
expect(segments.map(segment => segment.text).join("")).toBe(text)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapMarkdownProse", () => {
|
||||
it("transforms only prose segments", () => {
|
||||
const result = mapMarkdownProse(
|
||||
"TypeScript `TypeScript` [[TypeScript]]",
|
||||
text => text.replace(/TypeScript/g, "TS"),
|
||||
)
|
||||
|
||||
expect(result).toBe("TS `TypeScript` [[TypeScript]]")
|
||||
})
|
||||
})
|
||||
253
src/markdown-segments.ts
Normal file
253
src/markdown-segments.ts
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import { isMarkdownTableLine } from "./replace-links/candidate-scanner"
|
||||
|
||||
export type MarkdownSegmentKind = "prose" | "protected"
|
||||
|
||||
export type MarkdownProtectedKind = "inline-code"
|
||||
| "fenced-code"
|
||||
| "wikilink"
|
||||
| "markdown-link"
|
||||
| "single-bracket"
|
||||
| "url"
|
||||
| "heading"
|
||||
| "callout"
|
||||
| "table-row"
|
||||
|
||||
export interface MarkdownSegment {
|
||||
kind: MarkdownSegmentKind
|
||||
protectedKind?: MarkdownProtectedKind
|
||||
start: number
|
||||
end: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface SegmentMarkdownOptions {
|
||||
protectHeadings?: boolean
|
||||
protectCallouts?: boolean
|
||||
protectTableRows?: boolean
|
||||
protectUrls?: boolean
|
||||
}
|
||||
|
||||
interface ProtectedRange {
|
||||
start: number
|
||||
end: number
|
||||
protectedKind: MarkdownProtectedKind
|
||||
}
|
||||
|
||||
const collectHeadingRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const headingPattern = /^#{1,6}\s+.*$/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = headingPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "heading",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectCalloutRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = calloutPattern.exec(text)) !== null) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: "callout",
|
||||
})
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const collectTableRowRanges = (text: string): ProtectedRange[] => {
|
||||
const ranges: ProtectedRange[] = []
|
||||
const linePattern = /[^\n]*(?:\n|$)/g
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = linePattern.exec(text)) !== null) {
|
||||
if (match[0] === "") {
|
||||
break
|
||||
}
|
||||
|
||||
const lineText = match[0]
|
||||
const lineContent = lineText.endsWith("\n")
|
||||
? lineText.slice(0, -1).replace(/\r$/, "")
|
||||
: lineText.replace(/\r$/, "")
|
||||
|
||||
if (isMarkdownTableLine(lineContent)) {
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + lineText.length,
|
||||
protectedKind: "table-row",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return ranges
|
||||
}
|
||||
|
||||
const buildProtectedPattern = (protectUrls: boolean): RegExp => {
|
||||
const parts = [
|
||||
"```[\\s\\S]*?(?:```|$)",
|
||||
"~~~[\\s\\S]*?(?:~~~|$)",
|
||||
"`[^`]*`",
|
||||
"\\[\\[[^\\]]+\\]\\]",
|
||||
"\\[[^\\]]+\\]\\([^)]+\\)",
|
||||
"\\[[^\\]]+\\]",
|
||||
]
|
||||
|
||||
if (protectUrls) {
|
||||
parts.push("https?:\\/\\/[^\\s]+")
|
||||
}
|
||||
|
||||
return new RegExp(`(${parts.join("|")})`, "g")
|
||||
}
|
||||
|
||||
const getProtectedKind = (text: string): MarkdownProtectedKind => {
|
||||
if (text.startsWith("```") || text.startsWith("~~~")) {
|
||||
return "fenced-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("`")) {
|
||||
return "inline-code"
|
||||
}
|
||||
|
||||
if (text.startsWith("[[")) {
|
||||
return "wikilink"
|
||||
}
|
||||
|
||||
if (text.startsWith("[")) {
|
||||
return text.includes("](") ? "markdown-link" : "single-bracket"
|
||||
}
|
||||
|
||||
return "url"
|
||||
}
|
||||
|
||||
const sortAndMergeRanges = (ranges: ProtectedRange[]): ProtectedRange[] => {
|
||||
const sortedRanges = ranges
|
||||
.slice()
|
||||
.sort((a, b) => a.start - b.start || a.end - b.end)
|
||||
const merged: ProtectedRange[] = []
|
||||
|
||||
for (const range of sortedRanges) {
|
||||
const lastRange = merged[merged.length - 1]
|
||||
if (!lastRange || range.start >= lastRange.end) {
|
||||
merged.push({ ...range })
|
||||
continue
|
||||
}
|
||||
|
||||
lastRange.end = Math.max(lastRange.end, range.end)
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
const isInsideRanges = (
|
||||
index: number,
|
||||
ranges: ProtectedRange[],
|
||||
): boolean => {
|
||||
return ranges.some(range => index >= range.start && index < range.end)
|
||||
}
|
||||
|
||||
export const segmentMarkdown = (
|
||||
text: string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): MarkdownSegment[] => {
|
||||
const mayContainProtectedMarkdown = text.includes("`")
|
||||
|| text.includes("[")
|
||||
|| (options.protectHeadings && text.includes("#"))
|
||||
|| (options.protectCallouts && text.includes(">"))
|
||||
|| (options.protectTableRows && text.includes("|"))
|
||||
|| (options.protectUrls && text.includes("http"))
|
||||
|
||||
if (!mayContainProtectedMarkdown) {
|
||||
return [{
|
||||
kind: "prose",
|
||||
start: 0,
|
||||
end: text.length,
|
||||
text,
|
||||
}]
|
||||
}
|
||||
|
||||
const ranges: ProtectedRange[] = []
|
||||
|
||||
if (options.protectHeadings) {
|
||||
ranges.push(...collectHeadingRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectCallouts) {
|
||||
ranges.push(...collectCalloutRanges(text))
|
||||
}
|
||||
|
||||
if (options.protectTableRows) {
|
||||
ranges.push(...collectTableRowRanges(text))
|
||||
}
|
||||
|
||||
const protectedPattern = buildProtectedPattern(options.protectUrls ?? false)
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
while ((match = protectedPattern.exec(text)) !== null) {
|
||||
if (isInsideRanges(match.index, ranges)) {
|
||||
continue
|
||||
}
|
||||
|
||||
ranges.push({
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
protectedKind: getProtectedKind(match[0]),
|
||||
})
|
||||
}
|
||||
|
||||
const mergedRanges = sortAndMergeRanges(ranges)
|
||||
const segments: MarkdownSegment[] = []
|
||||
let cursor = 0
|
||||
|
||||
for (const range of mergedRanges) {
|
||||
if (cursor < range.start) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: range.start,
|
||||
text: text.slice(cursor, range.start),
|
||||
})
|
||||
}
|
||||
|
||||
segments.push({
|
||||
kind: "protected",
|
||||
protectedKind: range.protectedKind,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
text: text.slice(range.start, range.end),
|
||||
})
|
||||
cursor = range.end
|
||||
}
|
||||
|
||||
if (cursor < text.length || segments.length === 0) {
|
||||
segments.push({
|
||||
kind: "prose",
|
||||
start: cursor,
|
||||
end: text.length,
|
||||
text: text.slice(cursor),
|
||||
})
|
||||
}
|
||||
|
||||
return segments
|
||||
}
|
||||
|
||||
export const mapMarkdownProse = (
|
||||
text: string,
|
||||
transform: (segmentText: string, segment: MarkdownSegment) => string,
|
||||
options: SegmentMarkdownOptions = {},
|
||||
): string => {
|
||||
return segmentMarkdown(text, options)
|
||||
.map(segment => segment.kind === "prose"
|
||||
? transform(segment.text, segment)
|
||||
: segment.text)
|
||||
.join("")
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { CandidateData, TrieNode } from "../trie"
|
||||
import { mapMarkdownProse, segmentMarkdown } from "../markdown-segments"
|
||||
import {
|
||||
buildFallbackIndex,
|
||||
extractFencedCodeBlocks,
|
||||
extractLinkParts,
|
||||
findBestCandidateInSameNamespace,
|
||||
getCurrentNamespace,
|
||||
|
|
@ -9,7 +9,6 @@ import {
|
|||
isCjkText,
|
||||
isIndexInsideMarkdownTable,
|
||||
isKoreanText,
|
||||
isMarkdownTableLine,
|
||||
isMonthNote,
|
||||
isProtectedLink,
|
||||
isSelfLink,
|
||||
|
|
@ -211,6 +210,7 @@ const processCjkText = (
|
|||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
forceIsInTable?: boolean,
|
||||
): string => {
|
||||
// For CJK texts that might contain non-CJK terms like "taro-san", ensure we use a consistent approach
|
||||
// Pass the proper filePath to maintain correct namespace resolution
|
||||
|
|
@ -224,6 +224,7 @@ const processCjkText = (
|
|||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
forceIsInTable,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -236,6 +237,7 @@ const processFallbackSearch = (
|
|||
currentNamespace: string,
|
||||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings,
|
||||
forceIsInTable?: boolean,
|
||||
): { result: string, newIndex: number } | null => {
|
||||
// Early boundary check - if start isn't a word boundary, skip
|
||||
const prevChar = text[startIndex - 1]
|
||||
|
|
@ -345,7 +347,7 @@ const processFallbackSearch = (
|
|||
longestMatch.word,
|
||||
settings,
|
||||
)
|
||||
const isInTable = isIndexInsideMarkdownTable(text, startIndex)
|
||||
const isInTable = forceIsInTable ?? isIndexInsideMarkdownTable(text, startIndex)
|
||||
const finalLink = linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
|
|
@ -369,6 +371,7 @@ const handleKoreanSpecialCases = (
|
|||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
forceIsInTable?: boolean,
|
||||
): { result: string, newIndex: number } | null => {
|
||||
const remaining = text.slice(i + candidate.length)
|
||||
|
||||
|
|
@ -393,7 +396,7 @@ const handleKoreanSpecialCases = (
|
|||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable: false,
|
||||
isInTable: forceIsInTable ?? false,
|
||||
})
|
||||
|
||||
return {
|
||||
|
|
@ -423,6 +426,7 @@ const processStandardText = (
|
|||
linkGenerator: LinkGenerator,
|
||||
settings: ReplaceLinksSettings = {},
|
||||
resolvedAmbiguities?: Map<string, string>,
|
||||
forceIsInTable?: boolean,
|
||||
): string => {
|
||||
let result = ""
|
||||
let i = 0
|
||||
|
|
@ -517,6 +521,7 @@ const processStandardText = (
|
|||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
forceIsInTable,
|
||||
)
|
||||
if (koreanResult) {
|
||||
result += koreanResult.result
|
||||
|
|
@ -581,7 +586,7 @@ const processStandardText = (
|
|||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias,
|
||||
isInTable,
|
||||
isInTable: forceIsInTable ?? isInTable,
|
||||
})
|
||||
result += finalLink
|
||||
|
||||
|
|
@ -600,6 +605,7 @@ const processStandardText = (
|
|||
currentNamespace,
|
||||
linkGenerator,
|
||||
settings,
|
||||
forceIsInTable,
|
||||
)
|
||||
if (fallbackResult) {
|
||||
result += fallbackResult.result
|
||||
|
|
@ -642,8 +648,43 @@ export const replaceLinks = ({
|
|||
// Get the current namespace
|
||||
const currentNamespace = getCurrentNamespace(filePath, settings.baseDir)
|
||||
|
||||
const markdownOptions = {
|
||||
protectHeadings: settings.ignoreHeadings,
|
||||
protectCallouts: true,
|
||||
protectTableRows: settings.ignoreMarkdownTables,
|
||||
protectUrls: true,
|
||||
}
|
||||
|
||||
const replaceResolvedWikilink = (
|
||||
wikilink: string,
|
||||
start: number,
|
||||
): string => {
|
||||
if (!resolvedAmbiguities?.has(wikilink)) {
|
||||
return wikilink
|
||||
}
|
||||
|
||||
const resolvedPath = resolvedAmbiguities.get(wikilink)!
|
||||
const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath)
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/
|
||||
const linkMatch = wikilink.match(existingLinkRegex)
|
||||
const existingPath = linkMatch ? linkMatch[1] : ""
|
||||
const existingAlias = linkMatch ? linkMatch[2] : undefined
|
||||
const finalAlias = resolvedAlias || existingAlias || (wikilink.includes("|") ? undefined : existingPath)
|
||||
|
||||
return linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias: finalAlias,
|
||||
isInTable: !settings.ignoreMarkdownTables
|
||||
&& isIndexInsideMarkdownTable(body, start),
|
||||
})
|
||||
}
|
||||
|
||||
// Process segments of text
|
||||
const processTextSegment = (text: string): string => {
|
||||
const processTextSegment = (
|
||||
text: string,
|
||||
forceIsInTable?: boolean,
|
||||
): string => {
|
||||
// Check if the text contains CJK characters
|
||||
const hasCjkText = isCjkText(text)
|
||||
|
||||
|
|
@ -657,6 +698,7 @@ export const replaceLinks = ({
|
|||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
forceIsInTable,
|
||||
)
|
||||
}
|
||||
else {
|
||||
|
|
@ -670,138 +712,61 @@ export const replaceLinks = ({
|
|||
linkGenerator,
|
||||
settings,
|
||||
resolvedAmbiguities,
|
||||
forceIsInTable,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const processTableAwareTextSegment = (text: string): string => {
|
||||
if (!settings.ignoreMarkdownTables) {
|
||||
return processTextSegment(text)
|
||||
const processTableAwareTextSegment = (
|
||||
text: string,
|
||||
segment: { start: number },
|
||||
): string => {
|
||||
if (!text.includes("\n")) {
|
||||
const isInTable = !settings.ignoreMarkdownTables
|
||||
&& isIndexInsideMarkdownTable(body, segment.start)
|
||||
return processTextSegment(text, isInTable)
|
||||
}
|
||||
|
||||
return text.replace(/[^\n]*(?:\n|$)/g, (line) => {
|
||||
return text.replace(/[^\n]*(?:\n|$)/g, (line, offset) => {
|
||||
if (line === "") {
|
||||
return line
|
||||
}
|
||||
|
||||
const lineContent = line.endsWith("\n")
|
||||
? line.slice(0, -1)
|
||||
? line.slice(0, -1).replace(/\r$/, "")
|
||||
: line
|
||||
|
||||
if (isMarkdownTableLine(lineContent)) {
|
||||
if (lineContent === "") {
|
||||
return line
|
||||
}
|
||||
|
||||
return processTextSegment(line)
|
||||
const absoluteIndex = segment.start + offset
|
||||
const isInTable = !settings.ignoreMarkdownTables
|
||||
&& isIndexInsideMarkdownTable(body, absoluteIndex)
|
||||
|
||||
return processTextSegment(line, isInTable)
|
||||
})
|
||||
}
|
||||
|
||||
// Extract and protect fenced code blocks before any other block-level rules.
|
||||
const { body: bodyAfterCodeBlocks, codeBlocks } = extractFencedCodeBlocks(body)
|
||||
let bodyWithResolvedWikilinks = body
|
||||
if (resolvedAmbiguities) {
|
||||
bodyWithResolvedWikilinks = segmentMarkdown(body, markdownOptions)
|
||||
.map((segment) => {
|
||||
if (
|
||||
segment.kind === "protected"
|
||||
&& segment.protectedKind === "wikilink"
|
||||
) {
|
||||
return replaceResolvedWikilink(segment.text, segment.start)
|
||||
}
|
||||
|
||||
// Extract and protect headings first
|
||||
const headingPattern = /^#{1,6}\s+.*$/gm
|
||||
const headings: Array<{ placeholder: string, content: string }> = []
|
||||
let headingIndex = 0
|
||||
|
||||
let bodyAfterHeadings = bodyAfterCodeBlocks
|
||||
if (settings.ignoreHeadings) {
|
||||
bodyAfterHeadings = bodyAfterCodeBlocks.replace(headingPattern, (match) => {
|
||||
const placeholder = `__HEADING_${headingIndex}__`
|
||||
headings.push({ placeholder, content: match })
|
||||
headingIndex++
|
||||
return placeholder
|
||||
})
|
||||
}
|
||||
|
||||
// Extract and protect callout blocks first
|
||||
// Match callout blocks: starts with > [!type] and continues with lines starting with >
|
||||
const calloutPattern = /^>[ \t]*\[![\w-]+\].*?(\n>.*?)*(?=\n(?!>)|$)/gm
|
||||
const callouts: Array<{ placeholder: string, content: string }> = []
|
||||
let calloutIndex = 0
|
||||
|
||||
// Replace callouts with placeholders
|
||||
const bodyWithPlaceholders = bodyAfterHeadings.replace(calloutPattern, (match) => {
|
||||
const placeholder = `__CALLOUT_${calloutIndex}__`
|
||||
callouts.push({ placeholder, content: match })
|
||||
calloutIndex++
|
||||
return placeholder
|
||||
})
|
||||
|
||||
// Process the entire body while preserving protected segments
|
||||
let resultBody = ""
|
||||
let lastIndex = 0
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
// Reset the regex to start from the beginning
|
||||
REGEX_PATTERNS.PROTECTED.lastIndex = 0
|
||||
|
||||
while (
|
||||
(match = REGEX_PATTERNS.PROTECTED.exec(bodyWithPlaceholders)) !== null
|
||||
) {
|
||||
const mIndex = match.index
|
||||
const segment = bodyWithPlaceholders.slice(lastIndex, mIndex)
|
||||
resultBody += processTableAwareTextSegment(segment)
|
||||
|
||||
const fullMatch = match[0]
|
||||
if (
|
||||
settings.ignoreMarkdownTables
|
||||
&& isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex)
|
||||
) {
|
||||
resultBody += fullMatch
|
||||
}
|
||||
else if (resolvedAmbiguities?.has(fullMatch)) {
|
||||
// Existing link replacement
|
||||
const resolvedPath = resolvedAmbiguities.get(fullMatch)!
|
||||
const { linkPath, alias: resolvedAlias } = extractLinkParts(resolvedPath)
|
||||
|
||||
// Try to extract existing alias from the matched link
|
||||
const existingLinkRegex = /\[\[([^|\]]+)(?:\|([^\]]+))?\]\]/
|
||||
const linkMatch = fullMatch.match(existingLinkRegex)
|
||||
const existingPath = linkMatch ? linkMatch[1] : ""
|
||||
const existingAlias = linkMatch ? linkMatch[2] : undefined
|
||||
|
||||
// Use resolved alias if present, otherwise use existing alias,
|
||||
// otherwise use existing path (as alias if it was a simple link)
|
||||
const finalAlias = resolvedAlias || existingAlias || (fullMatch.includes("|") ? undefined : existingPath)
|
||||
|
||||
const isInTable = isIndexInsideMarkdownTable(bodyWithPlaceholders, mIndex)
|
||||
resultBody += linkGenerator({
|
||||
linkPath,
|
||||
sourcePath: filePath,
|
||||
alias: finalAlias,
|
||||
isInTable,
|
||||
return segment.text
|
||||
})
|
||||
}
|
||||
else {
|
||||
// Append the protected segment unchanged
|
||||
resultBody += fullMatch
|
||||
}
|
||||
lastIndex = mIndex + fullMatch.length
|
||||
|
||||
// Prevent infinite loop on zero-length matches
|
||||
if (fullMatch.length === 0) {
|
||||
REGEX_PATTERNS.PROTECTED.lastIndex++
|
||||
}
|
||||
.join("")
|
||||
}
|
||||
|
||||
// Process the remaining text
|
||||
resultBody += processTableAwareTextSegment(bodyWithPlaceholders.slice(lastIndex))
|
||||
|
||||
// Restore callouts
|
||||
for (const { placeholder, content } of callouts) {
|
||||
resultBody = resultBody.replace(placeholder, content)
|
||||
}
|
||||
|
||||
// Restore headings
|
||||
for (const { placeholder, content } of headings) {
|
||||
resultBody = resultBody.replace(placeholder, content)
|
||||
}
|
||||
|
||||
// Restore fenced code blocks
|
||||
for (const { placeholder, content } of codeBlocks) {
|
||||
resultBody = resultBody.replace(placeholder, content)
|
||||
}
|
||||
|
||||
return resultBody
|
||||
return mapMarkdownProse(
|
||||
bodyWithResolvedWikilinks,
|
||||
processTableAwareTextSegment,
|
||||
markdownOptions,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { mapMarkdownProse } from "../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
type Title = string
|
||||
interface ReplaceUrlWithTitleOptions {
|
||||
|
|
@ -13,103 +15,73 @@ export const replaceUrlWithTitle = ({
|
|||
return body
|
||||
}
|
||||
|
||||
let resultBody = body
|
||||
|
||||
// Sort URLs by length descending to replace longer URLs first
|
||||
// This helps prevent partial replacements (e.g., replacing 'example.com' before 'sub.example.com')
|
||||
const sortedUrls = Array.from(urlTitleMap.keys()).sort(
|
||||
(a, b) => b.length - a.length,
|
||||
)
|
||||
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url)
|
||||
// Should not happen with Map iteration, but good practice
|
||||
if (!title) continue
|
||||
const replaceUrlsInProse = (prose: string): string => {
|
||||
let resultBody = prose
|
||||
|
||||
// Escape backslashes and special characters in title for link text safety if needed
|
||||
// For now, assume title is safe.
|
||||
const markdownLink = `[${title}](${url})`
|
||||
let currentIndex = 0
|
||||
const newBodyParts: string[] = []
|
||||
for (const url of sortedUrls) {
|
||||
const title = urlTitleMap.get(url)
|
||||
if (!title) continue
|
||||
|
||||
// Find all occurrences of the current URL in the resultBody
|
||||
// resultBody is updated in each iteration of the outer loop
|
||||
while (currentIndex < resultBody.length) {
|
||||
// Find the next occurrence of the URL, case-sensitive.
|
||||
const nextOccurrence = resultBody.indexOf(url, currentIndex)
|
||||
const markdownLink = `[${title}](${url})`
|
||||
let currentIndex = 0
|
||||
const newBodyParts: string[] = []
|
||||
|
||||
if (nextOccurrence === -1) {
|
||||
// No more occurrences found, add the rest of the string
|
||||
newBodyParts.push(resultBody.substring(currentIndex))
|
||||
break
|
||||
}
|
||||
while (currentIndex < resultBody.length) {
|
||||
const nextOccurrence = resultBody.indexOf(url, currentIndex)
|
||||
|
||||
// Add the text segment before the match
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
)
|
||||
if (nextOccurrence === -1) {
|
||||
newBodyParts.push(resultBody.substring(currentIndex))
|
||||
break
|
||||
}
|
||||
|
||||
// --- Context Check ---
|
||||
let shouldReplace = true
|
||||
newBodyParts.push(
|
||||
resultBody.substring(currentIndex, nextOccurrence),
|
||||
)
|
||||
|
||||
// 1. Check if already part of a Markdown link: [...](url)
|
||||
// Look for `](` immediately before the URL and `)` immediately after.
|
||||
const precedingChars = resultBody.substring(
|
||||
nextOccurrence - 2,
|
||||
nextOccurrence,
|
||||
)
|
||||
const followingChar = resultBody[nextOccurrence + url.length]
|
||||
if (precedingChars === "](" && followingChar === ")") {
|
||||
shouldReplace = false
|
||||
}
|
||||
let shouldReplace = true
|
||||
|
||||
// 2. Check if inside inline code: `... url ...`
|
||||
// Count non-escaped backticks before the match. Odd count means inside code.
|
||||
if (shouldReplace) {
|
||||
const segmentBefore = resultBody.substring(0, nextOccurrence)
|
||||
// Count non-escaped backticks `(?<!\\)` ensures we don't count escaped ones like \`
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
const precedingChars = resultBody.substring(
|
||||
nextOccurrence - 2,
|
||||
nextOccurrence,
|
||||
)
|
||||
const followingChar = resultBody[nextOccurrence + url.length]
|
||||
if (precedingChars === "](" && followingChar === ")") {
|
||||
shouldReplace = false
|
||||
}
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
// Odd number of backticks means we might be inside a code span.
|
||||
// We need to ensure the code span doesn't close before our match.
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
// Check if there's another backtick between the last one and the match.
|
||||
// If not, we are inside the code span.
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
shouldReplace = false
|
||||
if (shouldReplace) {
|
||||
const segmentBefore = resultBody.substring(0, nextOccurrence)
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
shouldReplace = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
newBodyParts.push(shouldReplace ? markdownLink : url)
|
||||
currentIndex = nextOccurrence + url.length
|
||||
}
|
||||
|
||||
// 3. Check if inside fenced code block: ``` ... url ... ```
|
||||
// This check is complex and not implemented here for simplicity.
|
||||
// Assumes URLs within fenced code blocks should not be replaced by default.
|
||||
// A simple heuristic could check the lines around the match for ```,
|
||||
// but a proper parser state would be needed for accuracy.
|
||||
|
||||
// --- Apply Replacement ---
|
||||
if (shouldReplace) {
|
||||
newBodyParts.push(markdownLink)
|
||||
}
|
||||
else {
|
||||
// Keep the original URL if context checks failed
|
||||
newBodyParts.push(url)
|
||||
}
|
||||
|
||||
// Move index past the current match (either the original url or the replaced markdownLink)
|
||||
// Use url.length because that's what we searched for.
|
||||
currentIndex = nextOccurrence + url.length
|
||||
resultBody = newBodyParts.join("")
|
||||
}
|
||||
// Update resultBody for the next URL in the outer loop
|
||||
resultBody = newBodyParts.join("")
|
||||
|
||||
return resultBody
|
||||
}
|
||||
|
||||
return resultBody
|
||||
return mapMarkdownProse(body, replaceUrlsInProse)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { segmentMarkdown } from "../../markdown-segments"
|
||||
|
||||
type Url = string
|
||||
|
||||
// Regular expression to find URLs starting with http:// or https://
|
||||
|
|
@ -16,159 +18,79 @@ export const listupAllUrls = (
|
|||
const urls = new Set<Url>()
|
||||
let match
|
||||
|
||||
// --- Pre-calculate Fenced Code Block Ranges ---
|
||||
const codeBlockRanges: { start: number, end: number }[] = []
|
||||
// Regex to find fenced code blocks (handles different fence lengths and optional language specifiers)
|
||||
// Matches from ``` or ~~~ at the start of a line to the next ``` or ~~~ at the start of a line
|
||||
const codeBlockRegex
|
||||
= /^(?:```|~~~)[^\r\n]*?\r?\n([\s\S]*?)\r?\n^(?:```|~~~)$/gm
|
||||
let blockMatch
|
||||
while ((blockMatch = codeBlockRegex.exec(body)) !== null) {
|
||||
codeBlockRanges.push({
|
||||
start: blockMatch.index,
|
||||
end: blockMatch.index + blockMatch[0].length,
|
||||
})
|
||||
}
|
||||
// Reset regex state if needed, though new exec calls should handle this
|
||||
codeBlockRegex.lastIndex = 0
|
||||
for (const segment of segmentMarkdown(body)) {
|
||||
if (segment.kind === "protected") {
|
||||
continue
|
||||
}
|
||||
|
||||
while ((match = URL_REGEX.exec(body)) !== null) {
|
||||
const url = match[0]
|
||||
const matchIndex = match.index
|
||||
URL_REGEX.lastIndex = 0
|
||||
while ((match = URL_REGEX.exec(segment.text)) !== null) {
|
||||
const url = match[0]
|
||||
const matchIndex = segment.start + match.index
|
||||
|
||||
// --- Fenced Code Block Check ---
|
||||
// Check if the match index falls within any calculated code block range
|
||||
let isInCodeBlock = false
|
||||
for (const range of codeBlockRanges) {
|
||||
if (matchIndex >= range.start && matchIndex < range.end) {
|
||||
isInCodeBlock = true
|
||||
break
|
||||
let isBareUrl = true
|
||||
|
||||
if (matchIndex >= 2) {
|
||||
const followingCharIndex = matchIndex + url.length
|
||||
if (followingCharIndex < body.length && body[followingCharIndex] === ")") {
|
||||
const precedingChars = body.substring(matchIndex - 2, matchIndex)
|
||||
if (precedingChars === "](") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isInCodeBlock) {
|
||||
continue // Skip this URL if it's inside a fenced code block
|
||||
}
|
||||
|
||||
// --- Context Check ---
|
||||
let isBareUrl = true
|
||||
|
||||
// 1. Check if already part of a Markdown link: [...](url)
|
||||
if (matchIndex >= 2) { // Need space for "]("
|
||||
// Check if the URL is potentially followed by ')'
|
||||
const followingCharIndex = matchIndex + url.length
|
||||
if (followingCharIndex < body.length && body[followingCharIndex] === ")") {
|
||||
// If followed by ')', check if preceded by "]("
|
||||
const precedingChars = body.substring(matchIndex - 2, matchIndex)
|
||||
if (precedingChars === "](") {
|
||||
// Only if both conditions are met, it's a Markdown link
|
||||
if (isBareUrl && matchIndex >= 1) {
|
||||
const precedingChar = body[matchIndex - 1]
|
||||
const followingChar = body[matchIndex + url.length]
|
||||
if (precedingChar === "<" && followingChar === ">") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Check if enclosed in angle brackets: <url>
|
||||
if (isBareUrl && matchIndex >= 1) {
|
||||
const precedingChar = body[matchIndex - 1]
|
||||
const followingChar = body[matchIndex + url.length]
|
||||
if (precedingChar === "<" && followingChar === ">") {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
if (isBareUrl) {
|
||||
let finalUrl = url
|
||||
let shouldAdd = true
|
||||
|
||||
// 3. Check if inside inline code: `... url ...`
|
||||
// Count non-escaped backticks before the match. Odd count means inside code.
|
||||
if (isBareUrl) {
|
||||
const segmentBefore = body.substring(0, matchIndex)
|
||||
// Count non-escaped backticks `(?<!\\)` ensures we don't count escaped ones like \`
|
||||
const backticksCount = (segmentBefore.match(/(?<!\\)`/g) || [])
|
||||
.length
|
||||
|
||||
if (backticksCount % 2 !== 0) {
|
||||
// Odd number of backticks means we might be inside a code span.
|
||||
// We need to ensure the code span doesn't close *before* our match.
|
||||
const lastBacktickIndex = segmentBefore.lastIndexOf("`")
|
||||
// Check if there's another backtick between the last one and the match.
|
||||
// If not, we are inside the code span.
|
||||
if (
|
||||
lastBacktickIndex !== -1
|
||||
&& !segmentBefore
|
||||
.substring(lastBacktickIndex + 1)
|
||||
.includes("`")
|
||||
) {
|
||||
isBareUrl = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Check if inside fenced code block: ``` ... url ... ```
|
||||
// This check remains complex. A simple heuristic: check if the line
|
||||
// containing the URL is within a ``` block. This is not foolproof.
|
||||
// For now, we'll skip this check for simplicity, but acknowledge it's a limitation.
|
||||
// A more robust solution would involve parsing the Markdown structure.
|
||||
|
||||
// --- Check Ignored Domains & Clean URL ---
|
||||
if (isBareUrl) {
|
||||
let finalUrl = url
|
||||
let shouldAdd = true
|
||||
|
||||
// 5. Check Ignored Domains
|
||||
if (ignoredDomains && ignoredDomains.length > 0) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname = parsedUrl.hostname
|
||||
if (
|
||||
ignoredDomains.some(
|
||||
domain =>
|
||||
hostname === domain
|
||||
|| hostname.endsWith(`.${domain}`),
|
||||
if (ignoredDomains && ignoredDomains.length > 0) {
|
||||
try {
|
||||
const parsedUrl = new URL(url)
|
||||
const hostname = parsedUrl.hostname
|
||||
if (
|
||||
ignoredDomains.some(
|
||||
domain =>
|
||||
hostname === domain
|
||||
|| hostname.endsWith(`.${domain}`),
|
||||
)
|
||||
) {
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
console.warn(
|
||||
`Failed to parse URL for domain check: ${url}`,
|
||||
e,
|
||||
)
|
||||
) {
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
// If URL parsing fails, it's likely not a valid URL to add anyway
|
||||
console.warn(
|
||||
`Failed to parse URL for domain check: ${url}`,
|
||||
e,
|
||||
)
|
||||
shouldAdd = false
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Clean Trailing Punctuation (only if not ignored)
|
||||
// We only clean punctuation if the URL wasn't already excluded by context checks.
|
||||
if (shouldAdd) {
|
||||
const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "")
|
||||
// Ensure cleaning didn't make it invalid (e.g., just "http://")
|
||||
// or remove essential parts if the regex was too broad.
|
||||
if (cleanedUrl.includes("://")) {
|
||||
finalUrl = cleanedUrl // Use the cleaned URL only if it's still valid-looking
|
||||
if (shouldAdd) {
|
||||
const cleanedUrl = url.replace(TRAILING_PUNCTUATION_REGEX, "")
|
||||
if (cleanedUrl.includes("://")) {
|
||||
finalUrl = cleanedUrl
|
||||
}
|
||||
else {
|
||||
finalUrl = url
|
||||
console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
// If cleaning resulted in an invalid URL, maybe don't add it,
|
||||
// or reconsider the TRAILING_PUNCTUATION_REGEX.
|
||||
// For now, let's stick with the original URL if cleaning fails badly.
|
||||
// This case shouldn't happen often with the current regex.
|
||||
finalUrl = url // Revert to original if cleaning broke it
|
||||
console.warn(`URL cleaning potentially broke the URL: ${url} -> ${cleanedUrl}`)
|
||||
// Decide if we should still add the original potentially dirty url
|
||||
// shouldAdd = false; // Option: Don't add if cleaning failed
|
||||
}
|
||||
}
|
||||
|
||||
// --- Add URL if context checks pass and not ignored ---
|
||||
if (shouldAdd) {
|
||||
urls.add(finalUrl)
|
||||
if (shouldAdd) {
|
||||
urls.add(finalUrl)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reset regex lastIndex to avoid issues with overlapping matches or zero-length matches
|
||||
// Although our URL regex shouldn't produce zero-length matches.
|
||||
// If the regex finds a match at index `i`, the next search starts at `i + 1`.
|
||||
// If the match was length `l`, `exec` updates `lastIndex` to `i + l`.
|
||||
// We need to ensure progress even if `l` is 0, but `URL_REGEX` won't match empty.
|
||||
// If `isBareUrl` logic modified the string or indices, care would be needed.
|
||||
}
|
||||
|
||||
return urls
|
||||
|
|
|
|||
Loading…
Reference in a new issue