'use strict'; const path = require('path'); const posix = path.posix; // Matches inline markdown links and images: `](target)` / `](target "title")`. // We deliberately scope to the inline form because that is what skill/rule docs // use for cross-directory references. Reference-style and autolinks are left // untouched (they are rare in these files and carry higher false-positive risk). const INLINE_LINK_PATTERN = /(!?\]\()([^()\s]+)(\s+"[^"]*")?(\))/g; function toPosix(relativePath) { return String(relativePath || '').replace(/\\/g, '/').replace(/^\.\//, ''); } function stripTrailingSlash(value) { return value.length > 1 ? value.replace(/\/+$/, '') : value; } // Build file + directory lookup maps from the plan's own file placements. // `fileMappings` is a list of { sourceRel, destRel } where both are paths // relative to the repo root and the install root respectively. The directory // map is derived by walking shared ancestors of each source/dest pair, which is // exact for prefix-insertion namespacing (e.g. `skills/x` -> `skills/ecc/x`): // the path suffix below the inserted segment is preserved, so ancestor `k` // of the source maps to the dest with the matching number of trailing // segments removed. function buildInstallIndex(fileMappings) { const byFile = new Map(); const byDir = new Map(); for (const mapping of fileMappings || []) { const sourceRel = toPosix(mapping.sourceRel); const destRel = toPosix(mapping.destRel); if (!sourceRel || !destRel) { continue; } byFile.set(sourceRel, destRel); const sourceParts = sourceRel.split('/'); const destParts = destRel.split('/'); // Map every source ancestor directory to its installed counterpart by // removing the same count of trailing segments from the dest path. for (let depth = 1; depth < sourceParts.length; depth += 1) { const trailing = sourceParts.length - depth; const destDepth = destParts.length - trailing; if (destDepth < 1) { continue; } const sourceDir = sourceParts.slice(0, depth).join('/'); const destDir = destParts.slice(0, destDepth).join('/'); // Only record real prefix-insertion mappings (suffix preserved). If a // directory resolves to itself (no namespace change) we skip it so the // rewriter leaves those links alone. if (sourceDir !== destDir) { byDir.set(sourceDir, destDir); } } } return { byFile, byDir }; } function isExternalOrAnchor(target) { return ( target === '' || target.startsWith('#') || target.startsWith('/') || target.startsWith('mailto:') || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(target) // has a URL scheme (http:, https:, file:, ...) ); } // Resolve `target` (a relative link from `sourceDir`) to its repo-relative // path, then return the install-relative path it should point to, or null when // the target is not installed by this plan (leave such links untouched). function resolveInstalledTarget(target, sourceDir, index) { const hadTrailingSlash = target.endsWith('/'); const resolved = stripTrailingSlash(toPosix(posix.normalize(posix.join(sourceDir, target)))); // Escapes the repo root (starts with `..`) -> not something we placed. if (resolved === '' || resolved === '.' || resolved.startsWith('..')) { return null; } if (!hadTrailingSlash && index.byFile.has(resolved)) { return { installed: index.byFile.get(resolved), trailingSlash: false }; } if (index.byDir.has(resolved)) { return { installed: index.byDir.get(resolved), trailingSlash: hadTrailingSlash }; } return null; } // True when the plan installs `sourceRel` at a different relative path than the // source (i.e. a namespace segment was injected, e.g. skills/x -> skills/ecc/x). // Callers use this to keep non-namespaced files on the byte-for-byte copy path. function isNamespacedSource(sourceRel, index) { const normalizedSource = toPosix(sourceRel); const installedSource = index && index.byFile.get(normalizedSource); return Boolean(installedSource) && installedSource !== normalizedSource; } // Rewrite relative links in a single namespaced markdown file so they resolve // to the file's installed location. Returns the content unchanged when the // file itself was not namespaced or when no link needs adjustment. Pure: no IO. function rewriteRelativeLinks(content, options) { const { sourceRel, index } = options || {}; const normalizedSource = toPosix(sourceRel); const installedSource = index && index.byFile.get(normalizedSource); // Only rewrite when the file's own install path gained/changed a namespace // segment. If it lands at the same relative path, every link recomputes to // itself, so there is nothing to do. if (!installedSource || installedSource === normalizedSource) { return content; } const installedSourceDir = posix.dirname(installedSource); const sourceDir = posix.dirname(normalizedSource); const lines = String(content).split('\n'); let inFence = false; for (let i = 0; i < lines.length; i += 1) { const fenceToggle = /^\s*(```|~~~)/.test(lines[i]); if (fenceToggle) { inFence = !inFence; continue; } if (inFence) { continue; // never rewrite inside fenced code blocks } lines[i] = lines[i].replace( INLINE_LINK_PATTERN, (match, open, target, title, close) => { // Preserve any `#fragment` so anchors survive the rewrite. const hashIdx = target.indexOf('#'); const pathPart = hashIdx === -1 ? target : target.slice(0, hashIdx); const fragment = hashIdx === -1 ? '' : target.slice(hashIdx); if (isExternalOrAnchor(pathPart)) { return match; } const resolution = resolveInstalledTarget(pathPart, sourceDir, index); if (!resolution) { return match; } let rewritten = posix.relative(installedSourceDir, resolution.installed); if (rewritten === '') { rewritten = '.'; } if (resolution.trailingSlash && !rewritten.endsWith('/')) { rewritten += '/'; } // If the recomputed link points to the same place as the original // (e.g. an intra-namespace `./sibling.md` whose endpoints both shift by // the same prefix), keep the original text verbatim — including any // leading `./` — so the rewrite stays a strict no-op where it must. if (posix.normalize(rewritten) === posix.normalize(pathPart)) { return match; } return `${open}${rewritten}${fragment}${title || ''}${close}`; } ); } return lines.join('\n'); } module.exports = { buildInstallIndex, isNamespacedSource, rewriteRelativeLinks, };