d48cda4081
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled
180 lines
6.6 KiB
JavaScript
180 lines
6.6 KiB
JavaScript
'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,
|
|
};
|