ma-agents 3.15.0-beta.1 → 3.15.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/bmad-extension-plugin/.claude-plugin/marketplace.json +1 -1
- package/lib/knowledge-atlas/dist/atlas.cjs +90 -31
- package/lib/knowledge-atlas/src/render/document.ts +7 -1
- package/lib/knowledge-atlas/src/render/linkify.ts +168 -0
- package/package.json +1 -1
- package/skills/git-workflow-skill/skill.json +21 -21
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
"name": "ma-skills",
|
|
38
38
|
"source": "./",
|
|
39
39
|
"description": "ma-agents extension module providing enterprise SDLC personas and operational workflow skills.",
|
|
40
|
-
"version": "3.15.0-beta.
|
|
40
|
+
"version": "3.15.0-beta.2",
|
|
41
41
|
"author": {
|
|
42
42
|
"name": "Alon Mayaffit"
|
|
43
43
|
},
|
|
@@ -2005,11 +2005,11 @@ var require_lunr = __commonJS({
|
|
|
2005
2005
|
});
|
|
2006
2006
|
|
|
2007
2007
|
// lib/knowledge-atlas/src/cli.ts
|
|
2008
|
-
var
|
|
2008
|
+
var path12 = __toESM(require("path"));
|
|
2009
2009
|
|
|
2010
2010
|
// lib/knowledge-atlas/src/generate.ts
|
|
2011
2011
|
var fs11 = __toESM(require("fs"));
|
|
2012
|
-
var
|
|
2012
|
+
var path11 = __toESM(require("path"));
|
|
2013
2013
|
|
|
2014
2014
|
// lib/knowledge-atlas/src/scan/scanner.ts
|
|
2015
2015
|
var fs2 = __toESM(require("fs"));
|
|
@@ -5635,7 +5635,7 @@ function mergeRecords(unchanged, reprocessed, removed) {
|
|
|
5635
5635
|
|
|
5636
5636
|
// lib/knowledge-atlas/src/render/site.ts
|
|
5637
5637
|
var fs7 = __toESM(require("fs"));
|
|
5638
|
-
var
|
|
5638
|
+
var path7 = __toESM(require("path"));
|
|
5639
5639
|
|
|
5640
5640
|
// lib/knowledge-atlas/src/render/design-system.ts
|
|
5641
5641
|
var DESIGN_SYSTEM_CSS = `/* Knowledge Atlas \u2014 design system (Story 29.3) */
|
|
@@ -6301,7 +6301,7 @@ ${scripts.join("\n")}` : "";
|
|
|
6301
6301
|
|
|
6302
6302
|
// lib/knowledge-atlas/src/render/document.ts
|
|
6303
6303
|
var fs6 = __toESM(require("fs"));
|
|
6304
|
-
var
|
|
6304
|
+
var path6 = __toESM(require("path"));
|
|
6305
6305
|
|
|
6306
6306
|
// node_modules/markdown-it/lib/common/utils.mjs
|
|
6307
6307
|
var utils_exports = {};
|
|
@@ -11694,6 +11694,7 @@ function renderBacklinksHtml(backlinks, manifest, fromRel = "docs/page.html") {
|
|
|
11694
11694
|
}
|
|
11695
11695
|
|
|
11696
11696
|
// lib/knowledge-atlas/src/render/linkify.ts
|
|
11697
|
+
var path5 = __toESM(require("path"));
|
|
11697
11698
|
var ENTITY_PATTERNS = [
|
|
11698
11699
|
/\bNFR\d+\b/g,
|
|
11699
11700
|
// NFR62, NFR12, etc.
|
|
@@ -11822,6 +11823,63 @@ function linkifyText(text2, manifest, currentFileId, docFileIds, slugMap) {
|
|
|
11822
11823
|
result += text2.slice(last);
|
|
11823
11824
|
return result;
|
|
11824
11825
|
}
|
|
11826
|
+
function decodeHrefEntities(value) {
|
|
11827
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/�?39;/g, "'");
|
|
11828
|
+
}
|
|
11829
|
+
function encodeHrefForAttr(value) {
|
|
11830
|
+
return value.replace(/&/g, "&");
|
|
11831
|
+
}
|
|
11832
|
+
function resolveAuthoredHref(href, currentFileId, fileIdLookup, slugMap) {
|
|
11833
|
+
const trimmed = href.trim();
|
|
11834
|
+
if (trimmed === "") return null;
|
|
11835
|
+
if (/^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|#|\/\/)/.test(trimmed)) return null;
|
|
11836
|
+
if (trimmed.startsWith("/")) return null;
|
|
11837
|
+
const hashIdx = trimmed.indexOf("#");
|
|
11838
|
+
const rawPath = hashIdx === -1 ? trimmed : trimmed.slice(0, hashIdx);
|
|
11839
|
+
const fragment = hashIdx === -1 ? "" : trimmed.slice(hashIdx);
|
|
11840
|
+
if (rawPath === "") return null;
|
|
11841
|
+
let decodedPath;
|
|
11842
|
+
try {
|
|
11843
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
11844
|
+
} catch {
|
|
11845
|
+
decodedPath = rawPath;
|
|
11846
|
+
}
|
|
11847
|
+
const currentDir = path5.posix.dirname(currentFileId);
|
|
11848
|
+
const joined = currentDir === "." ? decodedPath : `${currentDir}/${decodedPath}`;
|
|
11849
|
+
const resolved = path5.posix.normalize(joined).replace(/\/+$/, "");
|
|
11850
|
+
if (resolved === ".." || resolved.startsWith("../")) return null;
|
|
11851
|
+
const matchedFileId = fileIdLookup.get(resolved.toLowerCase());
|
|
11852
|
+
if (!matchedFileId) return null;
|
|
11853
|
+
const targetSlug = slugMap.get(matchedFileId);
|
|
11854
|
+
if (!targetSlug) return null;
|
|
11855
|
+
if (matchedFileId === currentFileId) {
|
|
11856
|
+
return fragment || "#";
|
|
11857
|
+
}
|
|
11858
|
+
return `${targetSlug}.html${fragment}`;
|
|
11859
|
+
}
|
|
11860
|
+
function rewriteAuthoredLinks(html, manifest, currentFileId) {
|
|
11861
|
+
if (!html) return html;
|
|
11862
|
+
const fileIds = manifest.files.map((f) => f.path);
|
|
11863
|
+
if (fileIds.length === 0) return html;
|
|
11864
|
+
const slugMap = buildSlugMap(fileIds);
|
|
11865
|
+
const fileIdLookup = /* @__PURE__ */ new Map();
|
|
11866
|
+
for (const id of fileIds) {
|
|
11867
|
+
const key = id.toLowerCase();
|
|
11868
|
+
if (!fileIdLookup.has(key)) fileIdLookup.set(key, id);
|
|
11869
|
+
}
|
|
11870
|
+
return html.replace(/<a\b[^>]*>/gi, (tag) => {
|
|
11871
|
+
const hrefMatch = tag.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
11872
|
+
if (!hrefMatch) return tag;
|
|
11873
|
+
const usesDoubleQuote = hrefMatch[1] !== void 0;
|
|
11874
|
+
const rawAttrValue = usesDoubleQuote ? hrefMatch[1] : hrefMatch[2];
|
|
11875
|
+
const decoded = decodeHrefEntities(rawAttrValue);
|
|
11876
|
+
const rewritten = resolveAuthoredHref(decoded, currentFileId, fileIdLookup, slugMap);
|
|
11877
|
+
if (rewritten === null) return tag;
|
|
11878
|
+
const quote = usesDoubleQuote ? '"' : "'";
|
|
11879
|
+
const newAttr = `href=${quote}${encodeHrefForAttr(rewritten)}${quote}`;
|
|
11880
|
+
return tag.slice(0, hrefMatch.index) + newAttr + tag.slice(hrefMatch.index + hrefMatch[0].length);
|
|
11881
|
+
});
|
|
11882
|
+
}
|
|
11825
11883
|
var ALLOWED_TAGS = /* @__PURE__ */ new Set([
|
|
11826
11884
|
"p",
|
|
11827
11885
|
"a",
|
|
@@ -12053,7 +12111,7 @@ function getFileNodes(fileId, nodes) {
|
|
|
12053
12111
|
return nodes.filter((n) => n.fileId === fileId);
|
|
12054
12112
|
}
|
|
12055
12113
|
function renderDocumentPage(fileRec, manifest, projectRoot, navFlags, resolveAbsPath) {
|
|
12056
|
-
const absPath = resolveAbsPath?.(fileRec.path) ??
|
|
12114
|
+
const absPath = resolveAbsPath?.(fileRec.path) ?? path6.join(projectRoot, fileRec.path);
|
|
12057
12115
|
const title = fileTitle(fileRec.path);
|
|
12058
12116
|
let rawContent = "";
|
|
12059
12117
|
try {
|
|
@@ -12077,6 +12135,7 @@ function renderDocumentPage(fileRec, manifest, projectRoot, navFlags, resolveAbs
|
|
|
12077
12135
|
renderedHtml = addHeadingAnchors(renderedHtml);
|
|
12078
12136
|
const { html: mermaidHtml } = processMermaidFences(renderedHtml);
|
|
12079
12137
|
renderedHtml = mermaidHtml;
|
|
12138
|
+
renderedHtml = rewriteAuthoredLinks(renderedHtml, manifest, fileRec.path);
|
|
12080
12139
|
renderedHtml = linkifyHtml(renderedHtml, manifest, fileRec.path);
|
|
12081
12140
|
renderedHtml = sanitizeHtml(renderedHtml);
|
|
12082
12141
|
const fileIds = manifest.files.map((f) => f.path);
|
|
@@ -12334,7 +12393,7 @@ function esc6(s) {
|
|
|
12334
12393
|
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
12335
12394
|
}
|
|
12336
12395
|
function writeHtml(filePath, content) {
|
|
12337
|
-
fs7.mkdirSync(
|
|
12396
|
+
fs7.mkdirSync(path7.dirname(filePath), { recursive: true });
|
|
12338
12397
|
fs7.writeFileSync(filePath, content.replace(/\r\n/g, "\n"), { encoding: "utf-8" });
|
|
12339
12398
|
}
|
|
12340
12399
|
function renderHomePage(manifest, navFlags) {
|
|
@@ -12473,34 +12532,34 @@ function renderSite(manifest, outDir, resolveAbsPath) {
|
|
|
12473
12532
|
const written = [];
|
|
12474
12533
|
const docBodies = /* @__PURE__ */ new Map();
|
|
12475
12534
|
fs7.mkdirSync(outDir, { recursive: true });
|
|
12476
|
-
fs7.mkdirSync(
|
|
12535
|
+
fs7.mkdirSync(path7.join(outDir, "docs"), { recursive: true });
|
|
12477
12536
|
const fileIds = manifest.files.map((f) => f.path);
|
|
12478
12537
|
const slugMap = buildSlugMap(fileIds);
|
|
12479
12538
|
const navFlags = {
|
|
12480
|
-
hasGraphPage: fs7.existsSync(
|
|
12481
|
-
hasDiagramsPage: fs7.existsSync(
|
|
12482
|
-
hasOrphansPage: fs7.existsSync(
|
|
12539
|
+
hasGraphPage: fs7.existsSync(path7.join(outDir, "graph.html")),
|
|
12540
|
+
hasDiagramsPage: fs7.existsSync(path7.join(outDir, "diagrams.html")),
|
|
12541
|
+
hasOrphansPage: fs7.existsSync(path7.join(outDir, "orphans.html"))
|
|
12483
12542
|
};
|
|
12484
12543
|
for (const file of manifest.files) {
|
|
12485
12544
|
const slug = slugMap.get(file.path) ?? filePathToSlug(file.path);
|
|
12486
12545
|
const { html, articleText } = renderDocumentPage(file, manifest, manifest.projectRoot, navFlags, resolveAbsPath);
|
|
12487
12546
|
const relPath = `docs/${slug}.html`;
|
|
12488
|
-
writeHtml(
|
|
12547
|
+
writeHtml(path7.join(outDir, relPath), html);
|
|
12489
12548
|
written.push(relPath);
|
|
12490
12549
|
docBodies.set(file.path, articleText);
|
|
12491
12550
|
}
|
|
12492
12551
|
const searchHtml = renderSearchPageHtml(manifest, docBodies, navFlags);
|
|
12493
|
-
writeHtml(
|
|
12552
|
+
writeHtml(path7.join(outDir, "search.html"), searchHtml);
|
|
12494
12553
|
written.push("search.html");
|
|
12495
12554
|
const indexHtml = renderHomePage(manifest, navFlags);
|
|
12496
|
-
writeHtml(
|
|
12555
|
+
writeHtml(path7.join(outDir, "index.html"), indexHtml);
|
|
12497
12556
|
written.push("index.html");
|
|
12498
12557
|
return written.sort();
|
|
12499
12558
|
}
|
|
12500
12559
|
|
|
12501
12560
|
// lib/knowledge-atlas/src/render/graph.ts
|
|
12502
12561
|
var fs8 = __toESM(require("fs"));
|
|
12503
|
-
var
|
|
12562
|
+
var path8 = __toESM(require("path"));
|
|
12504
12563
|
|
|
12505
12564
|
// lib/knowledge-atlas/src/render/force-graph-umd.ts
|
|
12506
12565
|
var FORCE_GRAPH_UMD = `// Version 1.51.4 force-graph - https://github.com/vasturiano/force-graph
|
|
@@ -12963,7 +13022,7 @@ function renderGraphPage(manifest, outDir) {
|
|
|
12963
13022
|
""
|
|
12964
13023
|
// trailing newline
|
|
12965
13024
|
].join("\n");
|
|
12966
|
-
const outPath =
|
|
13025
|
+
const outPath = path8.join(outDir, "graph.html");
|
|
12967
13026
|
fs8.mkdirSync(outDir, { recursive: true });
|
|
12968
13027
|
fs8.writeFileSync(outPath, html.replace(/\r\n/g, "\n"), { encoding: "utf-8" });
|
|
12969
13028
|
return ["graph.html"];
|
|
@@ -13217,7 +13276,7 @@ function buildAppScript() {
|
|
|
13217
13276
|
|
|
13218
13277
|
// lib/knowledge-atlas/src/render/diagrams.ts
|
|
13219
13278
|
var fs9 = __toESM(require("fs"));
|
|
13220
|
-
var
|
|
13279
|
+
var path9 = __toESM(require("path"));
|
|
13221
13280
|
function esc8(s) {
|
|
13222
13281
|
return (s ?? "").toString().replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
13223
13282
|
}
|
|
@@ -13784,7 +13843,7 @@ function renderDiagrams(manifest, outDir) {
|
|
|
13784
13843
|
""
|
|
13785
13844
|
// trailing newline
|
|
13786
13845
|
].join("\n");
|
|
13787
|
-
const outPath =
|
|
13846
|
+
const outPath = path9.join(outDir, "diagrams.html");
|
|
13788
13847
|
fs9.mkdirSync(outDir, { recursive: true });
|
|
13789
13848
|
fs9.writeFileSync(outPath, html.replace(/\r\n/g, "\n"), { encoding: "utf-8" });
|
|
13790
13849
|
return ["diagrams.html"];
|
|
@@ -13792,7 +13851,7 @@ function renderDiagrams(manifest, outDir) {
|
|
|
13792
13851
|
|
|
13793
13852
|
// lib/knowledge-atlas/src/render/orphan.ts
|
|
13794
13853
|
var fs10 = __toESM(require("fs"));
|
|
13795
|
-
var
|
|
13854
|
+
var path10 = __toESM(require("path"));
|
|
13796
13855
|
function esc9(s) {
|
|
13797
13856
|
return (s ?? "").toString().replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
13798
13857
|
}
|
|
@@ -14154,7 +14213,7 @@ function renderOrphanPage(manifest, outDir) {
|
|
|
14154
14213
|
""
|
|
14155
14214
|
// trailing newline
|
|
14156
14215
|
].join("\n");
|
|
14157
|
-
const outPath =
|
|
14216
|
+
const outPath = path10.join(outDir, "orphans.html");
|
|
14158
14217
|
fs10.mkdirSync(outDir, { recursive: true });
|
|
14159
14218
|
fs10.writeFileSync(outPath, html.replace(/\r\n/g, "\n"), { encoding: "utf-8" });
|
|
14160
14219
|
return ["orphans.html"];
|
|
@@ -14163,7 +14222,7 @@ function renderOrphanPage(manifest, outDir) {
|
|
|
14163
14222
|
// lib/knowledge-atlas/src/generate.ts
|
|
14164
14223
|
var MANIFEST_VERSION = 2;
|
|
14165
14224
|
function normPath(p) {
|
|
14166
|
-
return
|
|
14225
|
+
return path11.resolve(p).replace(/[\\/]+$/, "");
|
|
14167
14226
|
}
|
|
14168
14227
|
function pathsEqual(a, b) {
|
|
14169
14228
|
return normPath(a) === normPath(b);
|
|
@@ -14171,7 +14230,7 @@ function pathsEqual(a, b) {
|
|
|
14171
14230
|
function isUnder(parent, child) {
|
|
14172
14231
|
const p = normPath(parent);
|
|
14173
14232
|
const c = normPath(child);
|
|
14174
|
-
return c.startsWith(p +
|
|
14233
|
+
return c.startsWith(p + path11.sep);
|
|
14175
14234
|
}
|
|
14176
14235
|
var MAX_TIME_VALUE_MS = 864e13;
|
|
14177
14236
|
function resolveTimestamp(now) {
|
|
@@ -14212,7 +14271,7 @@ function isoSecond(d) {
|
|
|
14212
14271
|
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
14213
14272
|
}
|
|
14214
14273
|
function deriveProjectTitle(projectRoot) {
|
|
14215
|
-
const pkgPath =
|
|
14274
|
+
const pkgPath = path11.join(projectRoot, "package.json");
|
|
14216
14275
|
try {
|
|
14217
14276
|
if (fs11.existsSync(pkgPath)) {
|
|
14218
14277
|
const raw = fs11.readFileSync(pkgPath, "utf-8");
|
|
@@ -14223,7 +14282,7 @@ function deriveProjectTitle(projectRoot) {
|
|
|
14223
14282
|
}
|
|
14224
14283
|
} catch {
|
|
14225
14284
|
}
|
|
14226
|
-
const base =
|
|
14285
|
+
const base = path11.basename(projectRoot).trim();
|
|
14227
14286
|
return base.length > 0 ? base : "Unknown Project";
|
|
14228
14287
|
}
|
|
14229
14288
|
function extractFileRecord(absPath, fileId, docType, contentHash2) {
|
|
@@ -14285,7 +14344,7 @@ function generate(opts) {
|
|
|
14285
14344
|
if (!projectRoot || typeof projectRoot !== "string") {
|
|
14286
14345
|
throw new Error("generate: projectRoot is required and must be a non-empty string");
|
|
14287
14346
|
}
|
|
14288
|
-
if (!
|
|
14347
|
+
if (!path11.isAbsolute(projectRoot)) {
|
|
14289
14348
|
throw new Error(`generate: projectRoot must be an absolute path (got: ${projectRoot})`);
|
|
14290
14349
|
}
|
|
14291
14350
|
const rootStat = fs11.statSync(projectRoot, { throwIfNoEntry: false });
|
|
@@ -14293,11 +14352,11 @@ function generate(opts) {
|
|
|
14293
14352
|
throw new Error(`generate: projectRoot does not exist or is not a directory: ${projectRoot}`);
|
|
14294
14353
|
}
|
|
14295
14354
|
const { sources, outputFolder, outputOverlap } = resolveKnowledgeSources(projectRoot);
|
|
14296
|
-
const bmadOutputDir =
|
|
14297
|
-
const defaultOutDir =
|
|
14355
|
+
const bmadOutputDir = path11.join(projectRoot, outputFolder);
|
|
14356
|
+
const defaultOutDir = path11.join(bmadOutputDir, "knowledge-site");
|
|
14298
14357
|
const outDir = outDirOverride ?? defaultOutDir;
|
|
14299
|
-
const rel =
|
|
14300
|
-
if (rel.startsWith("..") ||
|
|
14358
|
+
const rel = path11.relative(projectRoot, outDir);
|
|
14359
|
+
if (rel.startsWith("..") || path11.isAbsolute(rel)) {
|
|
14301
14360
|
throw new Error(`generate: outDir escapes projectRoot: ${outDir}`);
|
|
14302
14361
|
}
|
|
14303
14362
|
const generatedAt = resolveTimestamp(opts.now);
|
|
@@ -14332,7 +14391,7 @@ function generate(opts) {
|
|
|
14332
14391
|
const extraFileCount = graphFiles.length + diagramFiles.length + orphanFiles.length;
|
|
14333
14392
|
const written = renderSite(manifest, outDir, resolveAbsPath);
|
|
14334
14393
|
writeManifest(outDir, manifest);
|
|
14335
|
-
const indexPath =
|
|
14394
|
+
const indexPath = path11.join(outDir, "index.html");
|
|
14336
14395
|
return {
|
|
14337
14396
|
outDir,
|
|
14338
14397
|
indexPath,
|
|
@@ -14353,7 +14412,7 @@ function main() {
|
|
|
14353
14412
|
usage();
|
|
14354
14413
|
process.exit(args.length === 0 ? 1 : 0);
|
|
14355
14414
|
}
|
|
14356
|
-
const projectRoot =
|
|
14415
|
+
const projectRoot = path12.resolve(args[0]);
|
|
14357
14416
|
let outDir;
|
|
14358
14417
|
const outIdx = args.indexOf("--out");
|
|
14359
14418
|
if (outIdx !== -1) {
|
|
@@ -14361,7 +14420,7 @@ function main() {
|
|
|
14361
14420
|
process.stderr.write("[atlas] ERROR: --out requires a directory argument\n");
|
|
14362
14421
|
process.exit(1);
|
|
14363
14422
|
}
|
|
14364
|
-
outDir =
|
|
14423
|
+
outDir = path12.resolve(args[outIdx + 1]);
|
|
14365
14424
|
}
|
|
14366
14425
|
try {
|
|
14367
14426
|
const result = generate({ projectRoot, outDir });
|
|
@@ -36,7 +36,7 @@ import { headingToSlug } from '../extract/slug.js';
|
|
|
36
36
|
import { pageShell } from './layout.js';
|
|
37
37
|
import type { NavFlags } from './layout.js';
|
|
38
38
|
import { gatherBacklinks, renderBacklinksHtml } from './backlinks.js';
|
|
39
|
-
import { linkifyHtml, sanitizeHtml } from './linkify.js';
|
|
39
|
+
import { linkifyHtml, sanitizeHtml, rewriteAuthoredLinks } from './linkify.js';
|
|
40
40
|
import { resolveSlug } from './paths.js';
|
|
41
41
|
import type { FileRecord, KnowledgeManifest, GraphNode } from '../types.js';
|
|
42
42
|
|
|
@@ -262,6 +262,12 @@ export function renderDocumentPage(
|
|
|
262
262
|
const { html: mermaidHtml } = processMermaidFences(renderedHtml);
|
|
263
263
|
renderedHtml = mermaidHtml;
|
|
264
264
|
|
|
265
|
+
// Rewrite AUTHORED relative markdown cross-links (e.g. `[x](roadmap.md)`)
|
|
266
|
+
// to the RENDERED sibling doc page — bug fix (see render/linkify.ts for
|
|
267
|
+
// the full writeup). Must run before linkifyHtml/sanitizeHtml so the
|
|
268
|
+
// resolved href is what the safe-URL allowlist evaluates.
|
|
269
|
+
renderedHtml = rewriteAuthoredLinks(renderedHtml, manifest, fileRec.path);
|
|
270
|
+
|
|
265
271
|
// Linkify entity tokens (now that HTML structure is stable)
|
|
266
272
|
renderedHtml = linkifyHtml(renderedHtml, manifest, fileRec.path);
|
|
267
273
|
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* sanitizeHtml() — strip dangerous HTML from rendered markdown output
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
+
import * as path from 'path';
|
|
11
12
|
import type { KnowledgeManifest } from '../types.js';
|
|
12
13
|
import { filePathToSlug, buildSlugMap } from './paths.js';
|
|
13
14
|
|
|
@@ -240,6 +241,173 @@ function linkifyText(
|
|
|
240
241
|
return result;
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// rewriteAuthoredLinks — bug fix: authored relative `.md` cross-links
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
//
|
|
248
|
+
// CONFIRMED BUG (real Prophesys project, 18 broken links): reading-view docs
|
|
249
|
+
// contain AUTHORED markdown cross-links to sibling source docs, e.g. in
|
|
250
|
+
// `prd.md`: `[roadmap](roadmap.md#7-development-timetable)`. markdown-it
|
|
251
|
+
// renders these as plain `<a href="roadmap.md#...">` and `linkifyHtml` above
|
|
252
|
+
// only rewrites bare ENTITY TOKENS (FR26, Story 1.2, …) found in TEXT nodes —
|
|
253
|
+
// it never touches an href that already lives inside an authored `<a>` tag.
|
|
254
|
+
// Every reading view is written FLAT at `docs/<slug>.html` (render/paths.ts),
|
|
255
|
+
// so from `docs/prd.html` a raw `href="roadmap.md"` resolves to the
|
|
256
|
+
// non-existent `docs/roadmap.md` instead of the real `docs/roadmap.html`.
|
|
257
|
+
//
|
|
258
|
+
// This pass resolves the authored href against the SOURCE document's
|
|
259
|
+
// directory (not the rendered docs/ directory), looks the resolved path up
|
|
260
|
+
// in the SAME collision-free slug map every other renderer uses, and — only
|
|
261
|
+
// when it matches an actual scanned document — rewrites it to the sibling
|
|
262
|
+
// docs/-relative page. Anything that doesn't resolve to a scanned document
|
|
263
|
+
// (external URL, mailto:, in-page anchor, or a genuinely non-scanned file) is
|
|
264
|
+
// left byte-for-byte untouched: this pass never invents a target.
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Decode the HTML character references markdown-it's link renderer can
|
|
268
|
+
* introduce into an href attribute value (notably `&` for a literal `&`
|
|
269
|
+
* in the authored URL). Narrow by design — just enough to recover the
|
|
270
|
+
* original authored URL text for filesystem-path resolution; the decoded
|
|
271
|
+
* scheme/path is never emitted as-is (only a freshly-built `<slug>.html`
|
|
272
|
+
* href is written back), so there is no injection surface here.
|
|
273
|
+
*/
|
|
274
|
+
function decodeHrefEntities(value: string): string {
|
|
275
|
+
return value
|
|
276
|
+
.replace(/&/g, '&')
|
|
277
|
+
.replace(/</g, '<')
|
|
278
|
+
.replace(/>/g, '>')
|
|
279
|
+
.replace(/"/g, '"')
|
|
280
|
+
.replace(/�?39;/g, '\'');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Re-escape `&` for safe embedding back into an HTML attribute value. */
|
|
284
|
+
function encodeHrefForAttr(value: string): string {
|
|
285
|
+
return value.replace(/&/g, '&');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Resolve a single authored href (already HTML-entity-decoded) against the
|
|
290
|
+
* SOURCE document's directory. Returns the rewritten docs/-relative href
|
|
291
|
+
* (`<slug>.html[#fragment]`, since every doc page lives flat under `docs/`)
|
|
292
|
+
* or `null` when the href should be left untouched:
|
|
293
|
+
* - external / scheme'd links (`http:`, `mailto:`, `javascript:`, …),
|
|
294
|
+
* - protocol-relative (`//host/...`) and in-page (`#frag`) links,
|
|
295
|
+
* - site-root-absolute paths (`/x`) — not this bug's shape,
|
|
296
|
+
* - anything that normalizes outside the scanned tree (`../../../etc`),
|
|
297
|
+
* - anything that does not resolve (case-insensitively) to a fileId
|
|
298
|
+
* actually present in the manifest — a genuinely non-scanned target;
|
|
299
|
+
* never invent a link for it.
|
|
300
|
+
*/
|
|
301
|
+
function resolveAuthoredHref(
|
|
302
|
+
href: string,
|
|
303
|
+
currentFileId: string,
|
|
304
|
+
fileIdLookup: Map<string, string>,
|
|
305
|
+
slugMap: Map<string, string>,
|
|
306
|
+
): string | null {
|
|
307
|
+
const trimmed = href.trim();
|
|
308
|
+
if (trimmed === '') return null;
|
|
309
|
+
|
|
310
|
+
// External schemes (http:, https:, mailto:, tel:, javascript:, vbscript:,
|
|
311
|
+
// data:, ...), in-page fragments, and protocol-relative URLs are never
|
|
312
|
+
// authored-doc cross-links — leave them exactly as authored/sanitized.
|
|
313
|
+
if (/^(?:[a-zA-Z][a-zA-Z0-9+.-]*:|#|\/\/)/.test(trimmed)) return null;
|
|
314
|
+
// Site-root-absolute path — not the relative-link shape this bug covers.
|
|
315
|
+
if (trimmed.startsWith('/')) return null;
|
|
316
|
+
|
|
317
|
+
const hashIdx = trimmed.indexOf('#');
|
|
318
|
+
const rawPath = hashIdx === -1 ? trimmed : trimmed.slice(0, hashIdx);
|
|
319
|
+
const fragment = hashIdx === -1 ? '' : trimmed.slice(hashIdx);
|
|
320
|
+
if (rawPath === '') return null; // defensive: pure-fragment already caught above
|
|
321
|
+
|
|
322
|
+
// URL-decode so `%20` (space), `%2E` etc. match the real on-disk filename.
|
|
323
|
+
let decodedPath: string;
|
|
324
|
+
try {
|
|
325
|
+
decodedPath = decodeURIComponent(rawPath);
|
|
326
|
+
} catch {
|
|
327
|
+
decodedPath = rawPath; // malformed percent-escape — fall back to raw text
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Resolve RELATIVE TO THE SOURCE DOC'S DIRECTORY (the authored-link base),
|
|
331
|
+
// never the rendered docs/ directory the page happens to live under.
|
|
332
|
+
const currentDir = path.posix.dirname(currentFileId);
|
|
333
|
+
const joined = currentDir === '.' ? decodedPath : `${currentDir}/${decodedPath}`;
|
|
334
|
+
const resolved = path.posix.normalize(joined).replace(/\/+$/, '');
|
|
335
|
+
|
|
336
|
+
// Escaped above the scanned root — cannot be a scanned fileId.
|
|
337
|
+
if (resolved === '..' || resolved.startsWith('../')) return null;
|
|
338
|
+
|
|
339
|
+
// Case-insensitive match (`.md` vs `.MD`, mixed-case filenames) against the
|
|
340
|
+
// ACTUAL scanned fileIds — the same identity space every other renderer
|
|
341
|
+
// (linkifyHtml, backlinks, search) resolves against.
|
|
342
|
+
const matchedFileId = fileIdLookup.get(resolved.toLowerCase());
|
|
343
|
+
if (!matchedFileId) return null; // not a scanned document — leave unchanged
|
|
344
|
+
|
|
345
|
+
const targetSlug = slugMap.get(matchedFileId);
|
|
346
|
+
if (!targetSlug) return null;
|
|
347
|
+
|
|
348
|
+
// Self-link (authored link back to its own file): collapse to an in-page
|
|
349
|
+
// anchor, mirroring resolveHref()'s same-page handling above, instead of a
|
|
350
|
+
// full-page href to the page it's already on.
|
|
351
|
+
if (matchedFileId === currentFileId) {
|
|
352
|
+
return fragment || '#';
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return `${targetSlug}.html${fragment}`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Rewrite AUTHORED relative markdown cross-links — `[x](roadmap.md)`,
|
|
360
|
+
* `[x](sub/y.md#anchor)`, `[x](../z.MD)`, spaces/`%20`, mixed-case
|
|
361
|
+
* `.md`/`.MD` — so they point at the RENDERED sibling doc page instead of
|
|
362
|
+
* the raw source-relative path markdown-it passed through verbatim.
|
|
363
|
+
*
|
|
364
|
+
* Only touches `href` attributes on `<a>` tags already present in `html`
|
|
365
|
+
* (i.e. AUTHORED links); it never invents new links from plain text — that
|
|
366
|
+
* remains `linkifyHtml`'s job. Must run BEFORE `sanitizeHtml` so the
|
|
367
|
+
* rewritten href is what the safe-URL allowlist evaluates (a relative
|
|
368
|
+
* `<slug>.html[#frag]` href always passes it, matching prior behavior for
|
|
369
|
+
* already-relative authored links — no new unsafe-URL surface is created).
|
|
370
|
+
* Order relative to `linkifyHtml` does not matter: that pass only touches
|
|
371
|
+
* bare text nodes, never existing `<a>` attributes.
|
|
372
|
+
*/
|
|
373
|
+
export function rewriteAuthoredLinks(
|
|
374
|
+
html: string,
|
|
375
|
+
manifest: KnowledgeManifest,
|
|
376
|
+
currentFileId: string,
|
|
377
|
+
): string {
|
|
378
|
+
if (!html) return html;
|
|
379
|
+
|
|
380
|
+
const fileIds = manifest.files.map(f => f.path);
|
|
381
|
+
if (fileIds.length === 0) return html;
|
|
382
|
+
|
|
383
|
+
const slugMap = buildSlugMap(fileIds);
|
|
384
|
+
|
|
385
|
+
// Case-insensitive fileId lookup. First-encountered (sorted-order) wins on
|
|
386
|
+
// a case-fold collision — deterministic, matching the scanner's own
|
|
387
|
+
// first-wins dedup semantics.
|
|
388
|
+
const fileIdLookup = new Map<string, string>();
|
|
389
|
+
for (const id of fileIds) {
|
|
390
|
+
const key = id.toLowerCase();
|
|
391
|
+
if (!fileIdLookup.has(key)) fileIdLookup.set(key, id);
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
return html.replace(/<a\b[^>]*>/gi, (tag) => {
|
|
395
|
+
const hrefMatch = tag.match(/\bhref\s*=\s*(?:"([^"]*)"|'([^']*)')/i);
|
|
396
|
+
if (!hrefMatch) return tag;
|
|
397
|
+
|
|
398
|
+
const usesDoubleQuote = hrefMatch[1] !== undefined;
|
|
399
|
+
const rawAttrValue = usesDoubleQuote ? hrefMatch[1]! : hrefMatch[2]!;
|
|
400
|
+
const decoded = decodeHrefEntities(rawAttrValue);
|
|
401
|
+
|
|
402
|
+
const rewritten = resolveAuthoredHref(decoded, currentFileId, fileIdLookup, slugMap);
|
|
403
|
+
if (rewritten === null) return tag;
|
|
404
|
+
|
|
405
|
+
const quote = usesDoubleQuote ? '"' : '\'';
|
|
406
|
+
const newAttr = `href=${quote}${encodeHrefForAttr(rewritten)}${quote}`;
|
|
407
|
+
return tag.slice(0, hrefMatch.index!) + newAttr + tag.slice(hrefMatch.index! + hrefMatch[0].length);
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
|
|
243
411
|
// ---------------------------------------------------------------------------
|
|
244
412
|
// sanitizeHtml
|
|
245
413
|
// ---------------------------------------------------------------------------
|
package/package.json
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "Git Workflow",
|
|
3
|
-
"description": "MANDATORY worktree-based workflow for ALL file-changing activities. Enforces isolated feature branches, conventional commits, and PR-based merging.",
|
|
4
|
-
"version": "2.1.0",
|
|
5
|
-
"author": "AI Agent Skills",
|
|
6
|
-
"tags": [
|
|
7
|
-
"git",
|
|
8
|
-
"worktrees",
|
|
9
|
-
"workflow",
|
|
10
|
-
"branching",
|
|
11
|
-
"conventional-commits",
|
|
12
|
-
"pull-requests",
|
|
13
|
-
"multi-agent"
|
|
14
|
-
],
|
|
15
|
-
"applies_when": [
|
|
16
|
-
"committing changes",
|
|
17
|
-
"creating branches or PRs",
|
|
18
|
-
"any code writing or modification task"
|
|
19
|
-
],
|
|
20
|
-
"always_load": true
|
|
21
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "Git Workflow",
|
|
3
|
+
"description": "MANDATORY worktree-based workflow for ALL file-changing activities. Enforces isolated feature branches, conventional commits, and PR-based merging.",
|
|
4
|
+
"version": "2.1.0",
|
|
5
|
+
"author": "AI Agent Skills",
|
|
6
|
+
"tags": [
|
|
7
|
+
"git",
|
|
8
|
+
"worktrees",
|
|
9
|
+
"workflow",
|
|
10
|
+
"branching",
|
|
11
|
+
"conventional-commits",
|
|
12
|
+
"pull-requests",
|
|
13
|
+
"multi-agent"
|
|
14
|
+
],
|
|
15
|
+
"applies_when": [
|
|
16
|
+
"committing changes",
|
|
17
|
+
"creating branches or PRs",
|
|
18
|
+
"any code writing or modification task"
|
|
19
|
+
],
|
|
20
|
+
"always_load": true
|
|
21
|
+
}
|