import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { buildPageLookupMapFromCache, confluenceLinkToRelativePath } from '../markdown/link-converter.js';
import { buildPageStateFromFiles, type PageStateCache } from '../page-state.js';
import type { SpaceConfigWithState } from '../space-config.js';
/**
* Decode common HTML entities in a string
* Handles the most common entities: & < > " '
*/
function decodeHtmlEntities(text: string): string {
return text
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, "'")
.replace(/'/g, "'");
}
/**
* Result of second pass link resolution
*/
export interface LinkResolutionResult {
filesUpdated: number;
linksResolved: number;
warnings: string[];
}
/**
* Extract unresolved Confluence links from markdown content
* These are links that couldn't be resolved in the first pass and remain as:
* - Raw HTML:
* - Confluence URLs: [text](https://site.atlassian.net/wiki/...)
*
* @param content - Markdown content to scan
* @returns Array of {title, fullMatch} for each unresolved link
*/
function extractUnresolvedLinks(content: string): Array<{ title: string; fullMatch: string; linkText: string }> {
const unresolvedLinks: Array<{ title: string; fullMatch: string; linkText: string }> = [];
// Match elements with ri:page
// Pattern: ......
const acLinkPattern =
/\s*]*ri:content-title=["']([^"']+)["'][^>]*\/>\s*(?:<\/ac:plain-text-link-body>)?\s*<\/ac:link>/g;
let match: RegExpExecArray | null;
while ((match = acLinkPattern.exec(content)) !== null) {
// Decode HTML entities in title (e.g., "Page & Info" -> "Page & Info")
const decodedTitle = decodeHtmlEntities(match[1]);
const decodedLinkText = match[2] ? decodeHtmlEntities(match[2]) : decodedTitle;
unresolvedLinks.push({
title: decodedTitle,
fullMatch: match[0],
linkText: decodedLinkText,
});
}
return unresolvedLinks;
}
/**
* Perform second pass link resolution on all markdown files
*
* After the initial pull, some links couldn't be resolved because their target pages
* hadn't been pulled yet. This function rebuilds the page lookup map and resolves
* those links.
*
* Per ADR-0022: Converts Confluence page links to relative markdown paths
*
* @param directory - Space directory containing markdown files
* @param config - Space configuration with page mappings
* @returns Result with count of files and links updated
*/
export function resolveLinksSecondPass(directory: string, config: SpaceConfigWithState): LinkResolutionResult {
const result: LinkResolutionResult = {
filesUpdated: 0,
linksResolved: 0,
warnings: [],
};
// Rebuild page state cache from all files
const pageStateBuildResult = buildPageStateFromFiles(directory, config.pages);
const pageState: PageStateCache = pageStateBuildResult;
result.warnings.push(...pageStateBuildResult.warnings);
// Rebuild page lookup map with complete set of pages
const pageLookupMap = buildPageLookupMapFromCache(pageState, false);
// Scan all tracked pages for unresolved links
for (const [_pageId, localPath] of Object.entries(config.pages)) {
const fullPath = join(directory, localPath);
let content: string;
try {
content = readFileSync(fullPath, 'utf-8');
} catch (error) {
result.warnings.push(`Could not read ${localPath}: ${error}`);
continue;
}
// Find unresolved links in this file
const unresolvedLinks = extractUnresolvedLinks(content);
if (unresolvedLinks.length === 0) {
continue; // No unresolved links, skip this file
}
// Try to resolve each link
let updatedContent = content;
let linksResolvedInFile = 0;
for (const link of unresolvedLinks) {
// Try to convert to relative path
const relativePath = confluenceLinkToRelativePath(link.title, localPath, pageLookupMap);
if (relativePath) {
// Replace the unresolved link with markdown link
const markdownLink = `[${link.linkText}](${relativePath})`;
updatedContent = updatedContent.replace(link.fullMatch, markdownLink);
linksResolvedInFile++;
}
// If still can't resolve, leave it as-is (page might be in different space or deleted)
}
// Write updated content if any links were resolved
if (linksResolvedInFile > 0) {
try {
writeFileSync(fullPath, updatedContent, 'utf-8');
result.filesUpdated++;
result.linksResolved += linksResolvedInFile;
} catch (error) {
result.warnings.push(`Failed to update ${localPath}: ${error}`);
}
}
}
return result;
}