@doxbrix/doxloop 0.1.5 → 0.2.0

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.
Files changed (357) hide show
  1. package/CHANGELOG.md +1145 -0
  2. package/README.md +422 -227
  3. package/assets/doxbrix-preview.css +77 -15
  4. package/contracts/agent-events-v1.schema.json +1 -0
  5. package/contracts/coverage-v1.schema.json +59 -0
  6. package/contracts/drift-v1.schema.json +1 -0
  7. package/contracts/evaluation-v1.schema.json +62 -0
  8. package/contracts/quality-codes-v1.json +1 -0
  9. package/contracts/quality-report-v1.schema.json +1 -0
  10. package/contracts/validation-v1.schema.json +1 -0
  11. package/dist/agent-failure.d.ts +36 -0
  12. package/dist/agent-failure.js +98 -0
  13. package/dist/agent-isolation.d.ts +17 -0
  14. package/dist/agent-isolation.js +94 -0
  15. package/dist/agent-log.d.ts +197 -0
  16. package/dist/agent-log.js +1094 -0
  17. package/dist/agent-process.d.ts +63 -0
  18. package/dist/agent-process.js +128 -0
  19. package/dist/agent-reply.d.ts +47 -0
  20. package/dist/agent-reply.js +315 -0
  21. package/dist/agents.d.ts +24 -5
  22. package/dist/agents.js +60 -9
  23. package/dist/api-coverage.d.ts +11 -0
  24. package/dist/api-coverage.js +72 -0
  25. package/dist/application-probe.d.ts +28 -0
  26. package/dist/application-probe.js +93 -0
  27. package/dist/application-url.d.ts +14 -0
  28. package/dist/application-url.js +26 -0
  29. package/dist/args.js +15 -0
  30. package/dist/artifact-deploy.d.ts +9 -0
  31. package/dist/artifact-deploy.js +28 -8
  32. package/dist/assets.d.ts +72 -0
  33. package/dist/assets.js +382 -0
  34. package/dist/auth.d.ts +10 -0
  35. package/dist/auth.js +38 -9
  36. package/dist/author.d.ts +206 -4
  37. package/dist/author.js +1650 -52
  38. package/dist/authoring-batches.d.ts +190 -0
  39. package/dist/authoring-batches.js +489 -0
  40. package/dist/authoring-postpass.d.ts +79 -0
  41. package/dist/authoring-postpass.js +1289 -0
  42. package/dist/authoring-progress.d.ts +95 -0
  43. package/dist/authoring-progress.js +269 -0
  44. package/dist/autosync.d.ts +56 -0
  45. package/dist/autosync.js +442 -0
  46. package/dist/batch-artifacts.d.ts +81 -0
  47. package/dist/batch-artifacts.js +412 -0
  48. package/dist/batch-limits.d.ts +37 -0
  49. package/dist/batch-limits.js +80 -0
  50. package/dist/branding.d.ts +41 -0
  51. package/dist/branding.js +229 -0
  52. package/dist/bundle-upload.d.ts +26 -0
  53. package/dist/bundle-upload.js +99 -0
  54. package/dist/capture-auth.d.ts +95 -0
  55. package/dist/capture-auth.js +204 -0
  56. package/dist/capture.d.ts +48 -0
  57. package/dist/capture.js +20 -1
  58. package/dist/cli.js +839 -32
  59. package/dist/content-links.d.ts +10 -0
  60. package/dist/content-links.js +49 -0
  61. package/dist/contract-validation.d.ts +4 -0
  62. package/dist/contract-validation.js +25 -0
  63. package/dist/coverage-actions.d.ts +8 -0
  64. package/dist/coverage-actions.js +71 -0
  65. package/dist/coverage-resolutions.d.ts +16 -0
  66. package/dist/coverage-resolutions.js +58 -0
  67. package/dist/db.d.ts +37 -0
  68. package/dist/db.js +288 -0
  69. package/dist/demo.d.ts +12 -0
  70. package/dist/demo.js +122 -0
  71. package/dist/deploy-credentials.d.ts +7 -0
  72. package/dist/deploy-credentials.js +76 -0
  73. package/dist/deploy-targets/github-pages.d.ts +3 -0
  74. package/dist/deploy-targets/github-pages.js +100 -0
  75. package/dist/deploy-targets/index.d.ts +8 -0
  76. package/dist/deploy-targets/index.js +78 -0
  77. package/dist/deploy-targets/netlify.d.ts +3 -0
  78. package/dist/deploy-targets/netlify.js +49 -0
  79. package/dist/deploy-targets/types.d.ts +32 -0
  80. package/dist/deploy-targets/types.js +2 -0
  81. package/dist/deploy-targets/vercel.d.ts +3 -0
  82. package/dist/deploy-targets/vercel.js +95 -0
  83. package/dist/deploy.d.ts +9 -0
  84. package/dist/deploy.js +87 -13
  85. package/dist/deterministic-capture.d.ts +90 -0
  86. package/dist/deterministic-capture.js +435 -0
  87. package/dist/direct-edit.d.ts +40 -0
  88. package/dist/direct-edit.js +159 -0
  89. package/dist/docs-crawl.d.ts +86 -0
  90. package/dist/docs-crawl.js +536 -0
  91. package/dist/docs-site.d.ts +49 -0
  92. package/dist/docs-site.js +233 -0
  93. package/dist/doctor.js +8 -0
  94. package/dist/documentation-collections.d.ts +23 -0
  95. package/dist/documentation-collections.js +191 -0
  96. package/dist/documentation-plan.d.ts +253 -0
  97. package/dist/documentation-plan.js +2563 -0
  98. package/dist/doxbrix-build.d.ts +19 -0
  99. package/dist/doxbrix-build.js +167 -0
  100. package/dist/doxbrix-markdown.d.ts +9 -0
  101. package/dist/doxbrix-markdown.js +115 -14
  102. package/dist/drift.d.ts +10 -0
  103. package/dist/drift.js +164 -0
  104. package/dist/evaluation.d.ts +46 -0
  105. package/dist/evaluation.js +113 -0
  106. package/dist/evidence-pack.d.ts +47 -0
  107. package/dist/evidence-pack.js +358 -0
  108. package/dist/evidence.d.ts +25 -0
  109. package/dist/evidence.js +175 -0
  110. package/dist/fs.d.ts +8 -2
  111. package/dist/fs.js +41 -11
  112. package/dist/generator-api.d.ts +89 -0
  113. package/dist/generator-preflight.d.ts +27 -0
  114. package/dist/generator-preflight.js +105 -0
  115. package/dist/generator-runtime.d.ts +7 -0
  116. package/dist/generator-runtime.js +17 -1
  117. package/dist/generators.d.ts +24 -2
  118. package/dist/generators.js +54 -1
  119. package/dist/git-delivery.d.ts +17 -0
  120. package/dist/git-delivery.js +123 -0
  121. package/dist/globs.d.ts +16 -0
  122. package/dist/globs.js +65 -0
  123. package/dist/glossary.d.ts +26 -0
  124. package/dist/glossary.js +179 -0
  125. package/dist/history.d.ts +106 -0
  126. package/dist/history.js +600 -0
  127. package/dist/html-markdown.d.ts +46 -0
  128. package/dist/html-markdown.js +423 -0
  129. package/dist/interactive.js +3 -0
  130. package/dist/job-events.d.ts +74 -0
  131. package/dist/job-events.js +377 -0
  132. package/dist/keep-awake.d.ts +50 -0
  133. package/dist/keep-awake.js +123 -0
  134. package/dist/local-source-snapshot.d.ts +20 -0
  135. package/dist/local-source-snapshot.js +61 -0
  136. package/dist/mintlify-detect.d.ts +3 -0
  137. package/dist/mintlify-detect.js +18 -0
  138. package/dist/mintlify-import.d.ts +75 -0
  139. package/dist/mintlify-import.js +190 -0
  140. package/dist/navigation.d.ts +98 -0
  141. package/dist/navigation.js +310 -0
  142. package/dist/openapi.d.ts +60 -0
  143. package/dist/openapi.js +439 -0
  144. package/dist/page-editor-bridge.d.ts +3 -0
  145. package/dist/page-editor-bridge.js +109 -0
  146. package/dist/page-editor-preview.d.ts +10 -0
  147. package/dist/page-editor-preview.js +55 -0
  148. package/dist/page-extension.d.ts +9 -0
  149. package/dist/page-extension.js +15 -0
  150. package/dist/page-metadata.d.ts +28 -0
  151. package/dist/page-metadata.js +166 -0
  152. package/dist/page-operations.d.ts +34 -0
  153. package/dist/page-operations.js +215 -0
  154. package/dist/page-routes.d.ts +4 -0
  155. package/dist/page-routes.js +61 -0
  156. package/dist/pages.d.ts +20 -0
  157. package/dist/pages.js +184 -0
  158. package/dist/plan-generator.d.ts +3 -0
  159. package/dist/plan-generator.js +21 -0
  160. package/dist/plan-navigation.d.ts +11 -0
  161. package/dist/plan-navigation.js +30 -0
  162. package/dist/planning-captures.d.ts +20 -0
  163. package/dist/planning-captures.js +143 -0
  164. package/dist/planning-research.d.ts +135 -0
  165. package/dist/planning-research.js +472 -0
  166. package/dist/planning-triage.d.ts +23 -0
  167. package/dist/planning-triage.js +131 -0
  168. package/dist/preview.d.ts +24 -0
  169. package/dist/preview.js +280 -29
  170. package/dist/project-detect.d.ts +36 -0
  171. package/dist/project-detect.js +251 -0
  172. package/dist/project-import.d.ts +54 -0
  173. package/dist/project-import.js +157 -0
  174. package/dist/project-lock.d.ts +6 -0
  175. package/dist/project-lock.js +96 -0
  176. package/dist/project-registry.d.ts +25 -0
  177. package/dist/project-registry.js +79 -0
  178. package/dist/project.d.ts +35 -3
  179. package/dist/project.js +388 -38
  180. package/dist/prompts.d.ts +9 -0
  181. package/dist/prompts.js +25 -0
  182. package/dist/proposal-replay.d.ts +32 -0
  183. package/dist/proposal-replay.js +99 -0
  184. package/dist/quality-claims.d.ts +8 -0
  185. package/dist/quality-claims.js +168 -0
  186. package/dist/quality-config.d.ts +5 -0
  187. package/dist/quality-config.js +84 -0
  188. package/dist/quality-contract.d.ts +37 -0
  189. package/dist/quality-contract.js +46 -0
  190. package/dist/quality-examples.d.ts +4 -0
  191. package/dist/quality-examples.js +233 -0
  192. package/dist/quality-gates.d.ts +16 -0
  193. package/dist/quality-gates.js +192 -0
  194. package/dist/quality-links.d.ts +7 -0
  195. package/dist/quality-links.js +149 -0
  196. package/dist/quality-lint.d.ts +6 -0
  197. package/dist/quality-lint.js +124 -0
  198. package/dist/quality-rendered.d.ts +11 -0
  199. package/dist/quality-rendered.js +222 -0
  200. package/dist/quality-schema.d.ts +3 -0
  201. package/dist/quality-schema.js +71 -0
  202. package/dist/release-notes.d.ts +44 -0
  203. package/dist/release-notes.js +183 -0
  204. package/dist/remote-monitor.d.ts +16 -0
  205. package/dist/remote-monitor.js +74 -0
  206. package/dist/remote-source.d.ts +34 -0
  207. package/dist/remote-source.js +426 -0
  208. package/dist/review-diff.d.ts +82 -0
  209. package/dist/review-diff.js +400 -0
  210. package/dist/review-learning.d.ts +11 -0
  211. package/dist/review-learning.js +60 -0
  212. package/dist/review-render.d.ts +38 -0
  213. package/dist/review-render.js +224 -0
  214. package/dist/review-report.d.ts +9 -0
  215. package/dist/review-report.js +89 -0
  216. package/dist/review-ui.d.ts +14 -0
  217. package/dist/review-ui.js +1248 -0
  218. package/dist/schedule.d.ts +78 -0
  219. package/dist/schedule.js +480 -0
  220. package/dist/screen-capture-provider.d.ts +52 -0
  221. package/dist/screen-capture-provider.js +218 -0
  222. package/dist/screenshot-workflow.d.ts +167 -0
  223. package/dist/screenshot-workflow.js +1237 -0
  224. package/dist/settings.d.ts +1 -1
  225. package/dist/settings.js +95 -7
  226. package/dist/site-export.d.ts +18 -0
  227. package/dist/site-export.js +87 -0
  228. package/dist/source-connectors.d.ts +33 -0
  229. package/dist/source-connectors.js +268 -0
  230. package/dist/source-discovery.d.ts +132 -0
  231. package/dist/source-discovery.js +823 -0
  232. package/dist/source-intelligence.d.ts +9 -0
  233. package/dist/source-intelligence.js +306 -0
  234. package/dist/sync-review.d.ts +28 -0
  235. package/dist/sync-review.js +264 -0
  236. package/dist/sync-runs.d.ts +192 -0
  237. package/dist/sync-runs.js +2244 -0
  238. package/dist/sync.d.ts +35 -0
  239. package/dist/sync.js +298 -32
  240. package/dist/text-diff.d.ts +9 -0
  241. package/dist/text-diff.js +59 -0
  242. package/dist/types.d.ts +946 -1
  243. package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
  244. package/dist/ui/assets/index-BHBYU2aG.css +1 -0
  245. package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
  246. package/dist/ui/index.html +18 -0
  247. package/dist/ui-server.d.ts +83 -0
  248. package/dist/ui-server.js +3532 -0
  249. package/dist/usage-budget.d.ts +28 -0
  250. package/dist/usage-budget.js +90 -0
  251. package/dist/validation.d.ts +25 -1
  252. package/dist/validation.js +312 -26
  253. package/dist/workspace-tools.d.ts +54 -0
  254. package/dist/workspace-tools.js +123 -0
  255. package/docs/agent-compatibility.md +49 -32
  256. package/docs/ci-and-automation.md +105 -66
  257. package/docs/doxbrix-http-api.md +8 -1
  258. package/docs/existing-documentation.md +80 -0
  259. package/docs/generation-performance.md +108 -0
  260. package/docs/generator-authoring.md +68 -5
  261. package/docs/generator-selection.md +50 -13
  262. package/docs/mintlify-import.md +71 -0
  263. package/docs/openapi-security.md +25 -0
  264. package/docs/project-format.md +331 -36
  265. package/docs/release-quality.md +158 -0
  266. package/docs/releasing.md +72 -0
  267. package/docs/review-workflows.md +51 -0
  268. package/docs/security-model.md +102 -39
  269. package/docs/troubleshooting.md +226 -93
  270. package/package.json +46 -15
  271. package/scripts/test-auto-screenshot.mjs +172 -0
  272. package/skills/doxloop-authoring/SKILL.md +242 -361
  273. package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
  274. package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
  275. package/skills/doxloop-authoring/references/page-depth.md +169 -0
  276. package/skills/doxloop-authoring/references/project-format.md +106 -7
  277. package/skills/doxloop-authoring/references/quality.md +10 -0
  278. package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
  279. package/skills/doxloop-authoring/references/screenshots.md +149 -235
  280. package/skills/doxloop-authoring/references/workflows.md +76 -0
  281. package/skills/doxloop-doxbrix/SKILL.md +38 -22
  282. package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
  283. package/skills/doxloop-doxbrix/references/components.md +30 -3
  284. package/skills/doxloop-doxbrix/references/manifest.md +3 -2
  285. package/vendor/doxbrix-import/LICENSE +202 -0
  286. package/vendor/doxbrix-import/README.md +17 -0
  287. package/vendor/doxbrix-import/UPSTREAM.json +47 -0
  288. package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
  289. package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
  290. package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
  291. package/vendor/doxbrix-import/dist/docs/import.js +147 -0
  292. package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
  293. package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
  294. package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
  295. package/vendor/doxbrix-import/dist/docs/project.js +77 -0
  296. package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
  297. package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
  298. package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
  299. package/vendor/doxbrix-import/dist/importer.js +1567 -0
  300. package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
  301. package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
  302. package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
  303. package/vendor/doxbrix-import/dist/safe-path.js +47 -0
  304. package/dist/agents.d.ts.map +0 -1
  305. package/dist/agents.js.map +0 -1
  306. package/dist/args.d.ts.map +0 -1
  307. package/dist/args.js.map +0 -1
  308. package/dist/artifact-deploy.d.ts.map +0 -1
  309. package/dist/artifact-deploy.js.map +0 -1
  310. package/dist/auth.d.ts.map +0 -1
  311. package/dist/auth.js.map +0 -1
  312. package/dist/author.d.ts.map +0 -1
  313. package/dist/author.js.map +0 -1
  314. package/dist/capture.d.ts.map +0 -1
  315. package/dist/capture.js.map +0 -1
  316. package/dist/cli.d.ts.map +0 -1
  317. package/dist/cli.js.map +0 -1
  318. package/dist/deploy.d.ts.map +0 -1
  319. package/dist/deploy.js.map +0 -1
  320. package/dist/deployment-visibility.d.ts.map +0 -1
  321. package/dist/deployment-visibility.js.map +0 -1
  322. package/dist/doctor.d.ts.map +0 -1
  323. package/dist/doctor.js.map +0 -1
  324. package/dist/doxbrix-markdown.d.ts.map +0 -1
  325. package/dist/doxbrix-markdown.js.map +0 -1
  326. package/dist/errors.d.ts.map +0 -1
  327. package/dist/errors.js.map +0 -1
  328. package/dist/fs.d.ts.map +0 -1
  329. package/dist/fs.js.map +0 -1
  330. package/dist/generator-api.d.ts.map +0 -1
  331. package/dist/generator-api.js.map +0 -1
  332. package/dist/generator-manager.d.ts.map +0 -1
  333. package/dist/generator-manager.js.map +0 -1
  334. package/dist/generator-runtime.d.ts.map +0 -1
  335. package/dist/generator-runtime.js.map +0 -1
  336. package/dist/generators.d.ts.map +0 -1
  337. package/dist/generators.js.map +0 -1
  338. package/dist/interactive.d.ts.map +0 -1
  339. package/dist/interactive.js.map +0 -1
  340. package/dist/preview.d.ts.map +0 -1
  341. package/dist/preview.js.map +0 -1
  342. package/dist/progress.d.ts.map +0 -1
  343. package/dist/progress.js.map +0 -1
  344. package/dist/project.d.ts.map +0 -1
  345. package/dist/project.js.map +0 -1
  346. package/dist/prompts.d.ts.map +0 -1
  347. package/dist/prompts.js.map +0 -1
  348. package/dist/settings.d.ts.map +0 -1
  349. package/dist/settings.js.map +0 -1
  350. package/dist/sync.d.ts.map +0 -1
  351. package/dist/sync.js.map +0 -1
  352. package/dist/types.d.ts.map +0 -1
  353. package/dist/types.js.map +0 -1
  354. package/dist/validation.d.ts.map +0 -1
  355. package/dist/validation.js.map +0 -1
  356. package/dist/version.d.ts.map +0 -1
  357. package/dist/version.js.map +0 -1
@@ -0,0 +1,1289 @@
1
+ import { access, readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, extname, join, relative, resolve } from 'node:path';
3
+ import { assignSectionSpaces } from './plan-navigation.js';
4
+ import matter from 'gray-matter';
5
+ import { contentLinks } from './content-links.js';
6
+ import { isStarterContent } from './validation.js';
7
+ import { removeImageReference } from './screenshot-workflow.js';
8
+ import { EVIDENCE_MAP_FILE, readEvidenceMap, writeEvidenceMap } from './evidence.js';
9
+ import { pathExists, resolveContainedDirectory } from './fs.js';
10
+ import { readNavigation } from './navigation.js';
11
+ import { ROOT_CONTENT_IGNORED_DIRECTORIES, loadPages, loadSiteConfig, pageId, relativePath, siteConfigPath } from './project.js';
12
+ import { preferredPageExtension } from './page-extension.js';
13
+ const MAX_DESCRIPTION = 160;
14
+ const PAGE_EXTENSIONS = ['.md', '.mdx'];
15
+ const LINK_EXTENSIONS = ['.md', '.mdx', '.rst', '.html', '.htm'];
16
+ export async function applyAuthoringPostPass(input) {
17
+ const report = { repairs: [], problems: [] };
18
+ const root = resolve(input.workspace);
19
+ const doxbrix = isDoxbrix(input.project);
20
+ const contentRoot = await resolveContainedDirectory(root, input.project.contentDir, 'Documentation content directory', {
21
+ allowRoot: doxbrix,
22
+ });
23
+ const extensions = pageExtensionsFor(input.plan);
24
+ if (doxbrix)
25
+ await unifyPageExtensions(root, contentRoot, input, report);
26
+ const existing = [];
27
+ const missing = [];
28
+ let workspacePages;
29
+ for (const page of input.pages) {
30
+ const id = normalizePlanPath(page.path);
31
+ if (!id || !isInside(contentRoot, resolve(contentRoot, id))) {
32
+ report.problems.push(`${page.id}: planned path "${page.path}" is not inside the content directory, so it was skipped.`);
33
+ continue;
34
+ }
35
+ let absolute = await findPageFile(contentRoot, id, extensions);
36
+ let actualId = id;
37
+ if (!absolute) {
38
+ // The writer was told to write this page over a generated starter file
39
+ // (the landing page keeps the site's index; a starter quickstart keeps
40
+ // its name), so that file is the page.
41
+ workspacePages ??= (await loadPages(root, input.project).catch(() => [])).map((absolute) => pageId(contentRoot, absolute));
42
+ const replacement = starterReplacementId(id, workspacePages);
43
+ if (replacement) {
44
+ absolute = await findPageFile(contentRoot, replacement, extensions);
45
+ if (absolute)
46
+ actualId = replacement;
47
+ }
48
+ }
49
+ if (absolute)
50
+ existing.push({ page, id: actualId, planId: id, absolute, file: relativePath(root, absolute) });
51
+ else
52
+ missing.push({ page, id });
53
+ }
54
+ await repairFrontmatter(existing, report);
55
+ await repairCodeFences(existing, report);
56
+ if (doxbrix)
57
+ await repairDoxbrixNavigation(root, input.project, input.plan, existing, missing, report, input.pruneEmptySpaces === true);
58
+ else
59
+ await noteUnnavigatedPages(root, existing, report);
60
+ await repairLinks(root, contentRoot, input.project, input.plan, existing, report, input.unlinkUnresolved === true);
61
+ await repairEvidenceMap(root, contentRoot, input.plan, existing, extensions, report);
62
+ return report;
63
+ }
64
+ /**
65
+ * The starter file a planned page was written over, mirroring the contract
66
+ * `starterReplacements` gives the writer: a landing page (index, overview,
67
+ * home, start-here) is the site's index; any other page replaces the starter
68
+ * whose file name matches its last path segment.
69
+ */
70
+ export function starterReplacementId(planId, workspacePages) {
71
+ const stems = workspacePages.map((path) => normalizePlanPath(path.replace(/\.[^./]+$/, '')));
72
+ if (stems.includes(planId))
73
+ return undefined;
74
+ const last = planId.split('/').at(-1) ?? planId;
75
+ if (/^(?:index|overview|home|start-here)$/i.test(last) && stems.includes('index'))
76
+ return 'index';
77
+ return stems.find((stem) => stem.split('/').at(-1) === last);
78
+ }
79
+ // ---------------------------------------------------------------------------
80
+ // Page extensions
81
+ const NON_CONTENT_STEMS = /^(?:readme|changelog|license|contributing|security|code_of_conduct)$/i;
82
+ /**
83
+ * Rename new pages written as `.md` to `.mdx` in a Doxbrix site. Doxbrix
84
+ * reads both, but a real run wrote 12 of 62 pages as `.md` (the writer
85
+ * followed a seeded evidence key) and the proposal read as two conventions.
86
+ * Only pages this run created are renamed: every planned `create` page, and
87
+ * in a create-mode run's end-of-run pass any other content page, since the
88
+ * whole site is new. An existing page in an update keeps its file name, and a
89
+ * page with an `.mdx` twin is left for a human to reconcile. Navigation
90
+ * entries are extensionless and keep resolving; an entry or evidence-map key
91
+ * that spells the old file is updated with it.
92
+ */
93
+ async function unifyPageExtensions(root, contentRoot, input, report) {
94
+ if (preferredPageExtension(input.plan.target ?? { generator: input.project.generator }) !== '.mdx')
95
+ return;
96
+ const candidates = new Set();
97
+ for (const page of input.pages) {
98
+ if (page.action !== 'create')
99
+ continue;
100
+ const id = normalizePlanPath(page.path);
101
+ if (!id)
102
+ continue;
103
+ for (const file of [resolve(contentRoot, `${id}.md`), resolve(contentRoot, id, 'index.md')]) {
104
+ if (isInside(contentRoot, file))
105
+ candidates.add(file);
106
+ }
107
+ }
108
+ const endOfRun = input.pruneEmptySpaces === true || input.unlinkUnresolved === true;
109
+ if (endOfRun && input.plan.mode === 'create') {
110
+ try {
111
+ for (const absolute of await loadPages(root, input.project))
112
+ candidates.add(absolute);
113
+ }
114
+ catch {
115
+ // The planned pages are still checked.
116
+ }
117
+ }
118
+ const renamed = [];
119
+ for (const absolute of candidates) {
120
+ if (extname(absolute).toLowerCase() !== '.md')
121
+ continue;
122
+ const file = relativePath(root, absolute);
123
+ const segments = file.split('/');
124
+ if (segments.some((segment) => ROOT_CONTENT_IGNORED_DIRECTORIES.has(segment)))
125
+ continue;
126
+ if (NON_CONTENT_STEMS.test(segments.at(-1).replace(/\.md$/i, '')))
127
+ continue;
128
+ if (!(await pathExists(absolute)))
129
+ continue;
130
+ const target = absolute.replace(/\.md$/i, '.mdx');
131
+ if (await pathExists(target)) {
132
+ report.problems.push(`${file}: both ${file} and ${relativePath(root, target)} exist; keep one of them.`);
133
+ continue;
134
+ }
135
+ await rename(absolute, target);
136
+ renamed.push({ from: file, to: relativePath(root, target) });
137
+ report.repairs.push(`${file}: renamed to ${relativePath(root, target)} so every page uses the .mdx extension.`);
138
+ }
139
+ if (renamed.length === 0)
140
+ return;
141
+ try {
142
+ const map = await readEvidenceMap(root);
143
+ if (map) {
144
+ let changed = false;
145
+ for (const { from, to } of renamed) {
146
+ if (!map.pages[from])
147
+ continue;
148
+ if (!map.pages[to])
149
+ map.pages[to] = map.pages[from];
150
+ delete map.pages[from];
151
+ changed = true;
152
+ }
153
+ if (changed)
154
+ await writeEvidenceMap(root, map);
155
+ }
156
+ }
157
+ catch {
158
+ // A malformed evidence map is rebuilt by the evidence repair below.
159
+ }
160
+ try {
161
+ const configPath = await siteConfigPath(root, input.project);
162
+ const site = await loadSiteConfig(root, input.project);
163
+ const spelled = new Set(renamed.map(({ from }) => relative(contentRoot, resolve(root, from)).replaceAll('\\', '/')));
164
+ let changed = false;
165
+ const visit = (nodes) => {
166
+ for (const node of nodes) {
167
+ if (node.type === 'page') {
168
+ const bare = node.file.trim().replace(/^(?:\.\/)+/, '').replace(/^\/+/, '');
169
+ if (spelled.has(bare)) {
170
+ node.file = normalizePlanPath(node.file);
171
+ changed = true;
172
+ }
173
+ }
174
+ else if (node.type === 'group')
175
+ visit(node.items ?? []);
176
+ }
177
+ };
178
+ for (const space of site.spaces)
179
+ visit(space.nav);
180
+ if (changed)
181
+ await writeFile(configPath, `${JSON.stringify(site, null, 2)}\n`, 'utf8');
182
+ }
183
+ catch {
184
+ // Navigation is repaired, or reported, by the navigation pass.
185
+ }
186
+ }
187
+ // ---------------------------------------------------------------------------
188
+ // Frontmatter
189
+ async function repairFrontmatter(pages, report) {
190
+ for (const entry of pages) {
191
+ const content = await readFile(entry.absolute, 'utf8');
192
+ let data;
193
+ try {
194
+ data = matter(content).data;
195
+ }
196
+ catch (error) {
197
+ report.problems.push(`${entry.file}: frontmatter could not be parsed (${errorMessage(error)}), so title and description were not checked.`);
198
+ continue;
199
+ }
200
+ const fields = [];
201
+ if (!isText(data.title))
202
+ fields.push(['title', entry.page.title.trim() || labelFromId(entry.id)]);
203
+ if (!isText(data.description))
204
+ fields.push(['description', descriptionFor(entry.page)]);
205
+ if (fields.length === 0)
206
+ continue;
207
+ const next = insertFrontmatterFields(content, fields);
208
+ if (next === content)
209
+ continue;
210
+ await writeFile(entry.absolute, next, 'utf8');
211
+ report.repairs.push(`${entry.file}: added frontmatter ${fields.map(([key]) => key).join(' and ')}.`);
212
+ }
213
+ }
214
+ // ---------------------------------------------------------------------------
215
+ // Code fences
216
+ /**
217
+ * Give an unlabeled opening fence the `text` language. The validator warns on
218
+ * every bare fence and a real run spent two fix sessions adding `ini` and
219
+ * `text` by hand; `text` renders the block exactly as written, so it is never
220
+ * wrong, and a writer that knows better still labels its blocks itself.
221
+ */
222
+ export function labelBareCodeFences(content) {
223
+ let inside = false;
224
+ let fenceMarker = '';
225
+ return content.split(/(\r?\n)/).map((part) => {
226
+ const match = /^(\s*)(`{3,}|~{3,})(.*)$/.exec(part);
227
+ if (!match)
228
+ return part;
229
+ const [, indent, marker, rest] = match;
230
+ if (inside) {
231
+ if (marker[0] === fenceMarker[0] && marker.length >= fenceMarker.length && rest.trim() === '')
232
+ inside = false;
233
+ return part;
234
+ }
235
+ inside = true;
236
+ fenceMarker = marker;
237
+ return rest.trim() === '' ? `${indent}${marker}text` : part;
238
+ }).join('');
239
+ }
240
+ async function repairCodeFences(pages, report) {
241
+ for (const entry of pages) {
242
+ const content = await readFile(entry.absolute, 'utf8');
243
+ const next = labelBareCodeFences(content);
244
+ if (next === content)
245
+ continue;
246
+ await writeFile(entry.absolute, next, 'utf8');
247
+ report.repairs.push(`${entry.file}: labeled unlabeled code fences as text.`);
248
+ }
249
+ }
250
+ /**
251
+ * Insert or replace top-level frontmatter keys while leaving every other byte
252
+ * of the file alone. A blank existing key (`title:`) is replaced in place so
253
+ * the YAML never gains a duplicate mapping key.
254
+ */
255
+ export function insertFrontmatterFields(content, fields) {
256
+ const eol = content.includes('\r\n') ? '\r\n' : '\n';
257
+ const block = locateFrontmatter(content);
258
+ if (!block) {
259
+ const lines = fields.map(([key, value]) => `${key}: ${yamlString(value)}`);
260
+ return `---${eol}${lines.join(eol)}${eol}---${eol}${eol}${content}`;
261
+ }
262
+ let body = content.slice(block.bodyStart, block.bodyEnd);
263
+ const additions = [];
264
+ for (const [key, value] of fields) {
265
+ const line = `${key}: ${yamlString(value)}`;
266
+ const existingLine = new RegExp(`^${key}\\s*:.*$`, 'm');
267
+ if (existingLine.test(body))
268
+ body = body.replace(existingLine, line);
269
+ else
270
+ additions.push(line);
271
+ }
272
+ if (additions.length > 0)
273
+ body = body === '' ? `${additions.join(eol)}${eol}` : `${body}${eol}${additions.join(eol)}`;
274
+ return `${content.slice(0, block.bodyStart)}${body}${content.slice(block.bodyEnd)}`;
275
+ }
276
+ /** Byte offsets of the YAML between the opening and closing `---` lines. */
277
+ function locateFrontmatter(content) {
278
+ const open = content.match(/^\uFEFF?---[ \t]*(\r?\n)/);
279
+ if (!open)
280
+ return undefined;
281
+ const bodyStart = open[0].length;
282
+ const closing = /^---[ \t]*(?:\r?\n|$)/m;
283
+ const rest = content.slice(bodyStart);
284
+ const close = closing.exec(rest);
285
+ if (!close)
286
+ return undefined;
287
+ let bodyEnd = bodyStart + close.index;
288
+ // The body excludes the newline that ends its last line, so inserted lines
289
+ // can be appended with the file's own line ending.
290
+ if (bodyEnd > bodyStart && content[bodyEnd - 1] === '\n') {
291
+ bodyEnd -= content[bodyEnd - 2] === '\r' ? 2 : 1;
292
+ }
293
+ return { bodyStart, bodyEnd };
294
+ }
295
+ function yamlString(value) {
296
+ return JSON.stringify(value);
297
+ }
298
+ function descriptionFor(page) {
299
+ const purpose = page.purpose.replace(/\s+/g, ' ').trim();
300
+ const text = purpose || `Learn about ${page.title.trim() || labelFromId(normalizePlanPath(page.path))}.`;
301
+ return trimAtWordBoundary(text, MAX_DESCRIPTION);
302
+ }
303
+ export function trimAtWordBoundary(text, limit) {
304
+ if (text.length <= limit)
305
+ return text;
306
+ const cut = text.slice(0, limit);
307
+ const boundary = cut.lastIndexOf(' ');
308
+ const trimmed = (boundary > 0 ? cut.slice(0, boundary) : cut).replace(/[\s,;:(\-–—]+$/u, '');
309
+ return trimmed || cut.trimEnd();
310
+ }
311
+ // ---------------------------------------------------------------------------
312
+ // Navigation
313
+ async function repairDoxbrixNavigation(root, project, plan, existing, missing, report, pruneEmptySpaces = false) {
314
+ let configPath;
315
+ let site;
316
+ try {
317
+ configPath = await siteConfigPath(root, project);
318
+ site = await loadSiteConfig(root, project);
319
+ }
320
+ catch (error) {
321
+ report.problems.push(`Navigation could not be repaired: ${errorMessage(error)}`);
322
+ return;
323
+ }
324
+ const configFile = relativePath(root, configPath);
325
+ if (!site.spaces[0]) {
326
+ report.problems.push(`${configFile}: docs.json has no space, so pages could not be added to navigation.`);
327
+ return;
328
+ }
329
+ let changed = false;
330
+ const key = (value) => (value ?? '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
331
+ const spaceNamed = (label) => label ? site.spaces.find((candidate) => key(candidate.name) === key(label) || (candidate.slug !== undefined && key(candidate.slug) === key(label))) : undefined;
332
+ /**
333
+ * The space a page belongs to: the one its plan section names, else the one
334
+ * whose slug or name is the page's first path segment, else the first. A
335
+ * planner that promotes "Self-hosting" to a space puts self-hosting pages
336
+ * there, not in whichever space happens to be listed first.
337
+ */
338
+ const spaceFor = (entry, sectionTitle) => {
339
+ const bySection = spaceNamed(sectionTitle);
340
+ if (bySection)
341
+ return bySection;
342
+ const first = entry.id.includes('/') ? entry.id.split('/')[0] : undefined;
343
+ // "api/webhooks" belongs in an "API & integrations" space the plan lists.
344
+ const prefixed = first ? site.spaces.find((candidate) => {
345
+ const slug = key(candidate.slug ?? candidate.name);
346
+ return slug.startsWith(`${key(first)}-`) && (plan.navigation?.top ?? []).some((name) => key(name) === slug);
347
+ }) : undefined;
348
+ return spaceNamed(first) ?? prefixed ?? site.spaces[0];
349
+ };
350
+ promotePlannedAreas();
351
+ /**
352
+ * Batches write pages without seeing the whole plan, and the first batch
353
+ * usually lays the plan's top-level areas out as groups inside the one
354
+ * starter space. A multi-area plan is meant to open as spaces (the switcher
355
+ * above the sidebar), so while the site still has a single space, each area
356
+ * group becomes its own space in the plan's order. A site that already has
357
+ * several spaces was arranged on purpose and is left alone.
358
+ */
359
+ function promotePlannedAreas() {
360
+ const areas = (plan.navigation?.top ?? []).filter((name) => key(name));
361
+ if (areas.length < 2 || site.spaces.length !== 1)
362
+ return;
363
+ const original = site.spaces[0];
364
+ if (original.version || original.locale)
365
+ return;
366
+ const areaGroups = areas
367
+ .map((name) => ({ name, index: original.nav.findIndex((node) => node.type === 'group' && key(node.label) === key(name)) }))
368
+ .filter((entry) => entry.index >= 0);
369
+ if (areaGroups.length < 2)
370
+ return;
371
+ const byArea = new Map();
372
+ for (const { name } of areaGroups) {
373
+ const index = original.nav.findIndex((node) => node.type === 'group' && key(node.label) === key(name));
374
+ if (index < 0)
375
+ continue;
376
+ const [group] = original.nav.splice(index, 1);
377
+ byArea.set(key(name), { name, slug: key(name), ...(group.icon ? { icon: group.icon } : {}), nav: group.items ?? [] });
378
+ }
379
+ // What the writer left outside an area group (the landing page, starter
380
+ // groups) opens the first area that has no group of its own, else joins
381
+ // the first area, so no stray "Documentation" space stays beside them.
382
+ const leftovers = original.nav;
383
+ let placed = leftovers.length === 0;
384
+ const promoted = [];
385
+ for (const name of areas) {
386
+ const space = byArea.get(key(name));
387
+ if (space)
388
+ promoted.push(space);
389
+ else if (!placed && promoted.length === 0) {
390
+ promoted.push({ name, slug: key(name), ...(original.icon ? { icon: original.icon } : {}), nav: leftovers });
391
+ placed = true;
392
+ }
393
+ }
394
+ if (!placed)
395
+ promoted[0].nav.unshift(...leftovers);
396
+ site.spaces.splice(0, 1, ...promoted);
397
+ report.repairs.push(`${configFile}: split the navigation into ${promoted.length} spaces from the plan (${promoted.map((space) => space.name).join(', ')}).`);
398
+ changed = true;
399
+ }
400
+ const referenced = new Set();
401
+ const collect = (nodes) => {
402
+ for (const node of nodes) {
403
+ if (node.type === 'page')
404
+ referenced.add(navFileId(node.file));
405
+ else if (node.type === 'group')
406
+ collect(node.items ?? []);
407
+ }
408
+ };
409
+ for (const entry of site.spaces)
410
+ collect(entry.nav);
411
+ const sectionTitles = new Map();
412
+ const sectionSpaces = new Map();
413
+ // Plans saved before sections named their space get one from the same rule
414
+ // the planner's normalization uses.
415
+ const plannedSections = assignSectionSpaces(plan.navigation?.top ?? [], plan.navigation?.sections ?? []);
416
+ for (const section of plannedSections) {
417
+ for (const pageIdentifier of section.pageIds) {
418
+ if (!sectionTitles.has(pageIdentifier))
419
+ sectionTitles.set(pageIdentifier, section.title);
420
+ if (section.space && !sectionSpaces.has(pageIdentifier))
421
+ sectionSpaces.set(pageIdentifier, section.space);
422
+ }
423
+ }
424
+ // A space the plan does not name (the starter "Documentation" space) that
425
+ // still holds planned pages at the end of the run: release those pages so
426
+ // the placement below files each into its planned space and section group.
427
+ // The emptied starter groups and space are then pruned.
428
+ if (pruneEmptySpaces && (plan.navigation?.top ?? []).filter((name) => key(name)).length >= 2) {
429
+ const plannedKeys = new Set((plan.navigation?.top ?? []).map(key));
430
+ for (const candidate of site.spaces) {
431
+ if (candidate.version || candidate.locale)
432
+ continue;
433
+ if (plannedKeys.has(key(candidate.name)) || plannedKeys.has(key(candidate.slug ?? '')))
434
+ continue;
435
+ for (const entry of existing) {
436
+ if (!referenced.has(entry.id) || !sectionSpaces.has(entry.page.id))
437
+ continue;
438
+ const result = dropPageNodes(candidate.nav, entry.id);
439
+ if (result.removed === 0)
440
+ continue;
441
+ candidate.nav = result.nodes;
442
+ referenced.delete(entry.id);
443
+ changed = true;
444
+ report.repairs.push(`${configFile}: moved "${entry.id}" out of space "${candidate.name}", which the plan does not name, to its planned space.`);
445
+ }
446
+ }
447
+ }
448
+ /**
449
+ * The space the plan assigned a page's section to, created in the plan's
450
+ * `top` order when the writer has not made it yet. Batches write pages
451
+ * without seeing the whole plan, so this is what gives a multi-space plan
452
+ * its spaces.
453
+ */
454
+ const plannedSpace = (planPageId) => ensurePlannedSpace(sectionSpaces.get(planPageId));
455
+ function ensurePlannedSpace(name) {
456
+ if (!name)
457
+ return undefined;
458
+ const found = spaceNamed(name);
459
+ if (found)
460
+ return found;
461
+ const order = (plan.navigation?.top ?? []).map(key);
462
+ if (!order.includes(key(name)))
463
+ return undefined;
464
+ // The writer laid this area out as a group inside a space: keep that
465
+ // layout (the section nests under the area group) instead of a new space.
466
+ if (site.spaces.some((candidate) => candidate.nav.some((node) => node.type === 'group' && key(node.label) === key(name))))
467
+ return undefined;
468
+ const created = { name, slug: key(name), nav: [] };
469
+ const rank = order.indexOf(key(name));
470
+ const before = site.spaces.findIndex((candidate) => {
471
+ const candidateRank = order.indexOf(key(candidate.name));
472
+ return candidateRank > rank;
473
+ });
474
+ if (before === -1)
475
+ site.spaces.push(created);
476
+ else
477
+ site.spaces.splice(before, 0, created);
478
+ report.repairs.push(`${configFile}: created space "${name}" from the plan's navigation.`);
479
+ changed = true;
480
+ return created;
481
+ }
482
+ /**
483
+ * The top-level group a page's section belongs under, when the writer laid
484
+ * the plan's top-level areas out as groups inside one space rather than as
485
+ * spaces: "self-hosting/docker" belongs under a "Self-hosting" group, and
486
+ * "api/webhooks" under "API & integrations" when the plan lists that area.
487
+ * Without this the section group lands beside the area group, which stays
488
+ * an empty header the reader clicks into for nothing.
489
+ */
490
+ const topAreas = (plan.navigation?.top ?? []).map(key).filter(Boolean);
491
+ const parentGroupFor = (entry, space) => {
492
+ const segment = entry.id.includes('/') ? key(entry.id.split('/')[0]) : '';
493
+ if (!segment)
494
+ return undefined;
495
+ return space.nav.find((node) => {
496
+ if (node.type !== 'group')
497
+ return false;
498
+ const groupKey = key(node.label);
499
+ return groupKey === segment || (groupKey.startsWith(`${segment}-`) && topAreas.includes(groupKey));
500
+ });
501
+ };
502
+ // A plan section's pages, as navigation ids, for finding the space that
503
+ // already holds most of them.
504
+ const navIdByPlanId = new Map(plan.pages.map((page) => [page.id, normalizePlanPath(page.path)]));
505
+ for (const entry of existing)
506
+ navIdByPlanId.set(entry.page.id, entry.id);
507
+ const sectionNavIds = new Map();
508
+ for (const section of plan.navigation?.sections ?? []) {
509
+ const ids = sectionNavIds.get(section.title) ?? new Set();
510
+ for (const pageIdentifier of section.pageIds) {
511
+ const navId = navIdByPlanId.get(pageIdentifier);
512
+ if (navId)
513
+ ids.add(navId);
514
+ }
515
+ sectionNavIds.set(section.title, ids);
516
+ }
517
+ /**
518
+ * Where a section's group lives when the page's own space has none: the
519
+ * existing group of that name holding the most pages, else the space that
520
+ * already holds most of the section's pages. A plan section is one group;
521
+ * a page whose path starts with another space's slug ("administration/
522
+ * oauth-sso" in "Getting started") joins it rather than opening a
523
+ * one-page duplicate in that other space.
524
+ */
525
+ const sectionHome = (label) => {
526
+ let best;
527
+ for (const candidate of site.spaces) {
528
+ const found = findGroup(candidate, label);
529
+ if (!found)
530
+ continue;
531
+ const pages = countPageNodes(found.items ?? []);
532
+ if (!best || pages > best.pages)
533
+ best = { space: candidate, group: found, pages };
534
+ }
535
+ if (best)
536
+ return { space: best.space, group: best.group };
537
+ const ids = sectionNavIds.get(label);
538
+ if (!ids || ids.size === 0)
539
+ return undefined;
540
+ let bySpace;
541
+ for (const candidate of site.spaces) {
542
+ const pages = countMatchingPages(candidate.nav, ids);
543
+ if (pages > 0 && (!bySpace || pages > bySpace.pages))
544
+ bySpace = { space: candidate, pages };
545
+ }
546
+ return bySpace ? { space: bySpace.space } : undefined;
547
+ };
548
+ for (const entry of existing) {
549
+ if (referenced.has(entry.id))
550
+ continue;
551
+ const node = { type: 'page', file: entry.id, title: entry.page.title };
552
+ const label = sectionTitles.get(entry.page.id);
553
+ // The plan places a section in a space: the space it names for the
554
+ // section, or a space the section is named after.
555
+ const explicit = plannedSpace(entry.page.id) ?? spaceNamed(label);
556
+ let space = explicit ?? spaceFor(entry, label);
557
+ // A section that is the space itself needs no group of the same name.
558
+ const groupLabel = label && key(label) !== key(space.name) && key(label) !== key(space.slug) ? label : undefined;
559
+ let group = groupLabel ? findGroup(space, groupLabel) : undefined;
560
+ if (groupLabel && !group && !explicit) {
561
+ const home = sectionHome(groupLabel);
562
+ if (home) {
563
+ space = home.space;
564
+ group = home.group;
565
+ }
566
+ }
567
+ if (groupLabel && !group) {
568
+ group = { type: 'group', label: groupLabel, items: [] };
569
+ const parent = parentGroupFor(entry, space);
570
+ if (parent) {
571
+ parent.items.push(group);
572
+ report.repairs.push(`${configFile}: created navigation group "${groupLabel}" under "${parent.label}" in space "${space.name}".`);
573
+ }
574
+ else {
575
+ // Keep the plan's section order: a section planned before existing groups goes before them.
576
+ const order = (plan.navigation?.sections ?? []).map((section) => key(section.title));
577
+ const rank = order.indexOf(key(groupLabel));
578
+ const before = rank < 0 ? -1 : space.nav.findIndex((node) => node.type === 'group' && order.indexOf(key(node.label)) > rank);
579
+ if (before === -1)
580
+ space.nav.push(group);
581
+ else
582
+ space.nav.splice(before, 0, group);
583
+ report.repairs.push(`${configFile}: created navigation group "${groupLabel}" in space "${space.name}".`);
584
+ }
585
+ }
586
+ else if (!group) {
587
+ const parent = parentGroupFor(entry, space);
588
+ if (parent)
589
+ group = parent;
590
+ }
591
+ if (group)
592
+ group.items.push(node);
593
+ else
594
+ space.nav.push(node);
595
+ referenced.add(entry.id);
596
+ changed = true;
597
+ report.repairs.push(`${configFile}: added "${entry.id}" to ${group ? `group "${group.label}"` : `space "${space.name}"`}.`);
598
+ }
599
+ // The end-of-run pass also places pages the plan did not name: a starter
600
+ // the writer rewrote into a real page, a page it added on its own. The
601
+ // validator demands they be navigable, and a page the reviewer can see in
602
+ // the sidebar beats a proposal that cannot be applied.
603
+ if (pruneEmptySpaces) {
604
+ let workspacePages = [];
605
+ try {
606
+ const contentRoot = await resolveContainedDirectory(root, project.contentDir, 'Documentation content directory', { allowRoot: true });
607
+ workspacePages = (await loadPages(root, project)).map((absolute) => pageId(contentRoot, absolute));
608
+ }
609
+ catch {
610
+ workspacePages = [];
611
+ }
612
+ // Navigation entries for pages that no longer exist (a starter the run
613
+ // removed, a page the writer renamed) are the validator's "missing page".
614
+ const present = new Set(workspacePages);
615
+ if (workspacePages.length > 0) {
616
+ for (const each of site.spaces) {
617
+ for (const id of [...referenced]) {
618
+ if (present.has(id))
619
+ continue;
620
+ const result = dropPageNodes(each.nav, id);
621
+ if (result.removed > 0) {
622
+ each.nav = result.nodes;
623
+ changed = true;
624
+ report.repairs.push(`${configFile}: removed "${id}" from navigation because no such page exists.`);
625
+ }
626
+ }
627
+ }
628
+ for (const id of [...referenced])
629
+ if (!present.has(id))
630
+ referenced.delete(id);
631
+ }
632
+ for (const id of workspacePages) {
633
+ if (referenced.has(id) || plan.pages.some((page) => page.action === 'remove' && normalizePlanPath(page.path) === id))
634
+ continue;
635
+ // A starter page the plan superseded is removed by the final check, not navigated.
636
+ const file = await findPageFile(await resolveContainedDirectory(root, project.contentDir, 'Documentation content directory', { allowRoot: true }), id, PAGE_EXTENSIONS);
637
+ if (file && isStarterContent(await readFile(file, 'utf8').catch(() => '')))
638
+ continue;
639
+ const space = spaceNamed(id.includes('/') ? id.split('/')[0] : undefined) ?? site.spaces[0];
640
+ space.nav.push({ type: 'page', file: id, title: labelFromId(id) });
641
+ referenced.add(id);
642
+ changed = true;
643
+ report.repairs.push(`${configFile}: added unplanned page "${id}" to space "${space.name}" so it is reachable.`);
644
+ }
645
+ }
646
+ for (const entry of missing) {
647
+ if (entry.page.action !== 'remove' || !referenced.has(entry.id))
648
+ continue;
649
+ let removed = 0;
650
+ for (const each of site.spaces) {
651
+ const result = dropPageNodes(each.nav, entry.id);
652
+ each.nav = result.nodes;
653
+ removed += result.removed;
654
+ }
655
+ if (removed > 0) {
656
+ changed = true;
657
+ report.repairs.push(`${configFile}: removed "${entry.id}" from navigation because the plan deletes it.`);
658
+ }
659
+ }
660
+ // The writer names groups too. A group holding most of one planned
661
+ // section's pages under another label ("Articles and discussions" for the
662
+ // planned "Articles and interactions", "Reference" for "Shared contract")
663
+ // escaped every rule below, so the approved structure never reached the
664
+ // site. Such a group takes its section's title; a planned page filed under
665
+ // another planned section's group then moves to its own section.
666
+ if (pruneEmptySpaces && plannedSections.length > 0) {
667
+ const sectionKeys = new Set(plannedSections.map((section) => key(section.title)));
668
+ const areaKeys = new Set((plan.navigation?.top ?? []).map(key));
669
+ const sectionIds = plannedSections.map((section) => ({ section, ids: new Set(section.pageIds.map((id) => navIdByPlanId.get(id)).filter((id) => Boolean(id))) }));
670
+ const pagesOf = (nodes) => nodes.flatMap((node) => node.type === 'page' ? [navFileId(node.file)] : node.type === 'group' ? pagesOf(node.items ?? []) : []);
671
+ const groupsOf = (nodes) => nodes.flatMap((node) => node.type === 'group' ? [node, ...groupsOf(node.items ?? [])] : []);
672
+ for (const candidate of site.spaces) {
673
+ for (const group of groupsOf(candidate.nav)) {
674
+ if (sectionKeys.has(key(group.label)) || areaKeys.has(key(group.label)))
675
+ continue;
676
+ const pages = pagesOf(group.items ?? []);
677
+ if (pages.length < 2)
678
+ continue;
679
+ const best = sectionIds
680
+ .map(({ section, ids }) => ({ section, overlap: pages.filter((id) => ids.has(id)).length }))
681
+ .sort((left, right) => right.overlap - left.overlap)[0];
682
+ if (!best || best.overlap < 2 || best.overlap / pages.length < 0.6)
683
+ continue;
684
+ if (site.spaces.some((each) => findGroups(each.nav, best.section.title).length > 0))
685
+ continue;
686
+ report.repairs.push(`${configFile}: renamed navigation group "${group.label}" to "${best.section.title}", the plan section its pages belong to.`);
687
+ group.label = best.section.title;
688
+ changed = true;
689
+ }
690
+ }
691
+ for (const { section, ids } of sectionIds) {
692
+ for (const candidate of site.spaces) {
693
+ for (const group of groupsOf(candidate.nav)) {
694
+ if (!sectionKeys.has(key(group.label)) || key(group.label) === key(section.title))
695
+ continue;
696
+ const stray = (group.items ?? []).filter((node) => node.type === 'page' && ids.has(navFileId(node.file)));
697
+ if (stray.length === 0)
698
+ continue;
699
+ let home = site.spaces.flatMap((each) => findGroups(each.nav, section.title))[0];
700
+ if (!home) {
701
+ const space = ensurePlannedSpace(section.space) ?? candidate;
702
+ home = { type: 'group', label: section.title, items: [] };
703
+ const order = plannedSections.map((item) => key(item.title));
704
+ const rank = order.indexOf(key(section.title));
705
+ const before = space.nav.findIndex((node) => node.type === 'group' && order.indexOf(key(node.label)) > rank);
706
+ if (before === -1)
707
+ space.nav.push(home);
708
+ else
709
+ space.nav.splice(before, 0, home);
710
+ report.repairs.push(`${configFile}: created navigation group "${section.title}" in space "${space.name}".`);
711
+ }
712
+ group.items = (group.items ?? []).filter((node) => !stray.includes(node));
713
+ home.items = [...(home.items ?? []), ...stray];
714
+ changed = true;
715
+ report.repairs.push(`${configFile}: moved ${stray.map((node) => `"${node.type === 'page' ? navFileId(node.file) : ''}"`).join(', ')} from group "${group.label}" to "${section.title}", where the plan puts ${stray.length === 1 ? 'it' : 'them'}.`);
716
+ }
717
+ }
718
+ }
719
+ }
720
+ // One plan section is one group, in the space the plan gives it. A section
721
+ // split across spaces (a writer's docs.json, or an older run's repair, put
722
+ // one of its pages under a same-named group elsewhere) is merged into the
723
+ // copy in its planned space, else into the copy holding most pages; a
724
+ // section that sits only in another space moves to its planned space.
725
+ if (pruneEmptySpaces) {
726
+ for (const section of plannedSections) {
727
+ if (spaceNamed(section.title))
728
+ continue;
729
+ const copies = [];
730
+ for (const candidate of site.spaces) {
731
+ for (const found of findGroups(candidate.nav, section.title))
732
+ copies.push({ space: candidate, group: found, pages: countPageNodes(found.items ?? []) });
733
+ }
734
+ if (copies.length === 0)
735
+ continue;
736
+ const target = ensurePlannedSpace(section.space);
737
+ let keep = target ? copies.find((copy) => copy.space === target) : undefined;
738
+ if (!keep && target) {
739
+ const group = { type: 'group', label: copies[0].group.label, ...(copies[0].group.icon ? { icon: copies[0].group.icon } : {}), items: [] };
740
+ target.nav.push(group);
741
+ keep = { space: target, group, pages: 0 };
742
+ copies.push(keep);
743
+ report.repairs.push(`${configFile}: moved navigation group "${group.label}" to space "${target.name}", where the plan puts it.`);
744
+ }
745
+ if (!keep) {
746
+ if (copies.length < 2)
747
+ continue;
748
+ keep = copies.reduce((best, copy) => (copy.pages > best.pages ? copy : best));
749
+ }
750
+ for (const copy of copies) {
751
+ if (copy === keep)
752
+ continue;
753
+ const moved = copy.group.items ?? [];
754
+ keep.group.items = [...(keep.group.items ?? []), ...moved];
755
+ copy.group.items = [];
756
+ changed = true;
757
+ report.repairs.push(`${configFile}: merged navigation group "${copy.group.label}" (${copy.pages} page${copy.pages === 1 ? '' : 's'}) from space "${copy.space.name}" into the one in space "${keep.space.name}".`);
758
+ }
759
+ }
760
+ }
761
+ // Batches finish out of order, so section groups land in whatever order the
762
+ // writers reached them. At the end of the run, each space lists the plan's
763
+ // sections in the plan's order; anything the plan does not name keeps its place.
764
+ if (pruneEmptySpaces) {
765
+ const order = (plan.navigation?.sections ?? []).map((section) => key(section.title));
766
+ for (const space of site.spaces) {
767
+ const ranked = space.nav.map((node, index) => ({ node, index, rank: node.type === 'group' ? order.indexOf(key(node.label)) : -1 }));
768
+ const slots = ranked.filter((entry) => entry.rank >= 0);
769
+ const sorted = [...slots].sort((left, right) => left.rank - right.rank);
770
+ if (sorted.every((entry, position) => entry === slots[position]))
771
+ continue;
772
+ const nav = [...space.nav];
773
+ slots.forEach((slot, position) => { nav[slot.index] = sorted[position].node; });
774
+ space.nav = nav;
775
+ changed = true;
776
+ report.repairs.push(`${configFile}: ordered the sections in space "${space.name}" as the plan lists them.`);
777
+ }
778
+ }
779
+ // A group the writer opened for an area but never filled — one it created
780
+ // from the plan's top-level list, or one every page was moved out of — is
781
+ // a header the reader clicks into for nothing. Between batches it stays: a
782
+ // later batch may still fill it.
783
+ if (pruneEmptySpaces) {
784
+ for (const space of site.spaces) {
785
+ const result = dropEmptyGroups(space.nav);
786
+ if (result.removed.length === 0)
787
+ continue;
788
+ space.nav = result.nodes;
789
+ changed = true;
790
+ for (const label of result.removed)
791
+ report.repairs.push(`${configFile}: removed empty navigation group "${label}" from space "${space.name}".`);
792
+ }
793
+ }
794
+ // A space the plan promised pages for but nothing landed in is an empty
795
+ // header entry; the validator flags it and readers click into nothing.
796
+ if (pruneEmptySpaces && site.spaces.length > 1) {
797
+ const kept = site.spaces.filter((space) => countPageNodes(space.nav) > 0);
798
+ if (kept.length > 0 && kept.length < site.spaces.length) {
799
+ for (const space of site.spaces) {
800
+ if (!kept.includes(space))
801
+ report.repairs.push(`${configFile}: removed space "${space.name}" because no page was written for it.`);
802
+ }
803
+ site.spaces = kept;
804
+ changed = true;
805
+ }
806
+ }
807
+ if (changed)
808
+ await writeFile(configPath, `${JSON.stringify(site, null, 2)}\n`, 'utf8');
809
+ }
810
+ function countPageNodes(nodes) {
811
+ let count = 0;
812
+ for (const node of nodes) {
813
+ if (node.type === 'page')
814
+ count += 1;
815
+ else if (node.type === 'group')
816
+ count += countPageNodes(node.items ?? []);
817
+ }
818
+ return count;
819
+ }
820
+ /** Groups with no page anywhere beneath them, removed depth-first so a parent emptied by the removal goes too. */
821
+ function dropEmptyGroups(nodes) {
822
+ const removed = [];
823
+ const kept = [];
824
+ for (const node of nodes) {
825
+ if (node.type !== 'group') {
826
+ kept.push(node);
827
+ continue;
828
+ }
829
+ const inner = dropEmptyGroups(node.items ?? []);
830
+ removed.push(...inner.removed);
831
+ if (countPageNodes(inner.nodes) === 0 && !inner.nodes.some((child) => child.type !== 'group')) {
832
+ removed.push(node.label);
833
+ continue;
834
+ }
835
+ kept.push(inner.removed.length > 0 ? { ...node, items: inner.nodes } : node);
836
+ }
837
+ return { nodes: kept, removed };
838
+ }
839
+ function countMatchingPages(nodes, ids) {
840
+ let count = 0;
841
+ for (const node of nodes) {
842
+ if (node.type === 'page' && ids.has(navFileId(node.file)))
843
+ count += 1;
844
+ else if (node.type === 'group')
845
+ count += countMatchingPages(node.items ?? [], ids);
846
+ }
847
+ return count;
848
+ }
849
+ /** Every group with this label, at any depth. */
850
+ function findGroups(nodes, label) {
851
+ const wanted = label.trim().toLowerCase();
852
+ const found = [];
853
+ for (const node of nodes) {
854
+ if (node.type !== 'group')
855
+ continue;
856
+ if (node.label.trim().toLowerCase() === wanted)
857
+ found.push(node);
858
+ else
859
+ found.push(...findGroups(node.items ?? [], label));
860
+ }
861
+ return found;
862
+ }
863
+ function findGroup(space, label) {
864
+ const wanted = label.trim().toLowerCase();
865
+ const search = (nodes) => {
866
+ for (const node of nodes) {
867
+ if (node.type !== 'group')
868
+ continue;
869
+ if (node.label.trim().toLowerCase() === wanted)
870
+ return node;
871
+ const nested = search(node.items ?? []);
872
+ if (nested)
873
+ return nested;
874
+ }
875
+ return undefined;
876
+ };
877
+ return search(space.nav);
878
+ }
879
+ function dropPageNodes(nodes, id) {
880
+ let removed = 0;
881
+ const kept = [];
882
+ for (const node of nodes) {
883
+ if (node.type === 'page' && navFileId(node.file) === id) {
884
+ removed += 1;
885
+ continue;
886
+ }
887
+ if (node.type === 'group') {
888
+ const inner = dropPageNodes(node.items ?? [], id);
889
+ removed += inner.removed;
890
+ kept.push(inner.removed > 0 ? { ...node, items: inner.nodes } : node);
891
+ continue;
892
+ }
893
+ kept.push(node);
894
+ }
895
+ return { nodes: kept, removed };
896
+ }
897
+ /** Doxbrix `file` references are page ids; tolerate `./`, `/`, and an extension. */
898
+ function navFileId(file) {
899
+ return normalizePlanPath(file);
900
+ }
901
+ async function noteUnnavigatedPages(root, existing, report) {
902
+ if (existing.length === 0)
903
+ return;
904
+ let orphans;
905
+ try {
906
+ const tree = await readNavigation(root);
907
+ if (!tree.editable)
908
+ return;
909
+ orphans = new Set(tree.orphans.map((orphan) => orphan.path));
910
+ }
911
+ catch {
912
+ return;
913
+ }
914
+ for (const entry of existing) {
915
+ if (orphans.has(entry.file)) {
916
+ report.problems.push(`${entry.file}: page is not in the navigation; validation will report it until it is added.`);
917
+ }
918
+ }
919
+ }
920
+ // ---------------------------------------------------------------------------
921
+ // Links
922
+ async function repairLinks(root, contentRoot, project, plan, existing, report, unlinkUnresolved = false) {
923
+ if (existing.length === 0)
924
+ return;
925
+ const canonical = new Set();
926
+ try {
927
+ for (const absolute of await loadPages(root, project))
928
+ canonical.add(pageId(contentRoot, absolute));
929
+ }
930
+ catch {
931
+ for (const entry of existing)
932
+ canonical.add(entry.id);
933
+ }
934
+ // A planned path that was written over a starter file (the landing page
935
+ // as the site's index) is linked by the file that exists, never by the
936
+ // planned path, which would be a broken link.
937
+ const aliases = new Map();
938
+ for (const entry of existing) {
939
+ if (entry.planId !== entry.id)
940
+ aliases.set(entry.planId, entry.id);
941
+ }
942
+ for (const page of plan.pages) {
943
+ if (page.action === 'remove')
944
+ continue;
945
+ const id = normalizePlanPath(page.path);
946
+ if (id)
947
+ canonical.add(aliases.get(id) ?? id);
948
+ }
949
+ const bySegment = new Map();
950
+ const register = (segment, id) => {
951
+ const known = bySegment.get(segment) ?? [];
952
+ if (!known.includes(id))
953
+ bySegment.set(segment, [...known, id]);
954
+ };
955
+ for (const id of canonical)
956
+ register(id.split('/').at(-1), id);
957
+ for (const [planId, id] of aliases)
958
+ register(planId.split('/').at(-1), id);
959
+ const hrefFor = (id, suffix) => (id === 'index' ? `/${suffix}` : `/${id}${suffix}`);
960
+ // The end-of-run sweep repairs every page in the workspace, not only the
961
+ // planned ones: a starter page the writer updated on its own carries the
962
+ // same broken links, and one of them blocks the whole proposal.
963
+ const targets = [...existing];
964
+ if (unlinkUnresolved) {
965
+ const seen = new Set(existing.map((entry) => entry.absolute));
966
+ try {
967
+ for (const absolute of await loadPages(root, project)) {
968
+ if (!seen.has(absolute) && PAGE_EXTENSIONS.includes(extname(absolute).toLowerCase()))
969
+ targets.push({ absolute, file: relativePath(root, absolute) });
970
+ }
971
+ }
972
+ catch {
973
+ // The planned pages are still repaired.
974
+ }
975
+ }
976
+ if (unlinkUnresolved)
977
+ await removeMissingImages(root, contentRoot, targets, report);
978
+ for (const entry of targets) {
979
+ const content = await readFile(entry.absolute, 'utf8');
980
+ const rewrites = new Map();
981
+ const unlinks = new Set();
982
+ for (const href of contentLinks(content)) {
983
+ if (!isLocalPageLink(href))
984
+ continue;
985
+ const [target, suffix] = splitSuffix(href);
986
+ let decoded;
987
+ try {
988
+ decoded = decodeURIComponent(target);
989
+ }
990
+ catch {
991
+ continue;
992
+ }
993
+ if (decoded === '' || isAssetPath(decoded))
994
+ continue;
995
+ if (await linkResolves(decoded, entry.absolute, contentRoot))
996
+ continue;
997
+ const normalizedTarget = normalizePlanPath(decoded);
998
+ const aliased = aliases.get(normalizedTarget);
999
+ const segment = decoded.replace(/\/+$/, '').split('/').at(-1).replace(/\.(?:md|mdx|html?)$/i, '');
1000
+ const candidates = aliased ? [aliased] : bySegment.get(segment) ?? [];
1001
+ if (candidates.length === 1) {
1002
+ const replacement = hrefFor(candidates[0], suffix);
1003
+ if (replacement !== href)
1004
+ rewrites.set(href, replacement);
1005
+ else if (unlinkUnresolved)
1006
+ unlinks.add(href);
1007
+ }
1008
+ else if (unlinkUnresolved) {
1009
+ unlinks.add(href);
1010
+ }
1011
+ else if (candidates.length === 0) {
1012
+ report.problems.push(`${entry.file}: link "${href}" does not resolve and no planned or existing page is named "${segment}".`);
1013
+ }
1014
+ else {
1015
+ report.problems.push(`${entry.file}: link "${href}" does not resolve and could mean any of ${candidates.map((id) => `/${id}`).join(', ')}.`);
1016
+ }
1017
+ }
1018
+ if (rewrites.size === 0 && unlinks.size === 0)
1019
+ continue;
1020
+ let next = content;
1021
+ for (const [from, to] of rewrites)
1022
+ next = rewriteLink(next, from, to);
1023
+ for (const href of unlinks)
1024
+ next = unlinkTarget(next, href);
1025
+ if (next === content)
1026
+ continue;
1027
+ await writeFile(entry.absolute, next, 'utf8');
1028
+ for (const [from, to] of rewrites)
1029
+ report.repairs.push(`${entry.file}: rewrote link ${from} -> ${to}.`);
1030
+ for (const href of unlinks)
1031
+ report.repairs.push(`${entry.file}: turned the link ${href} into plain text because no page exists for it.`);
1032
+ }
1033
+ }
1034
+ /**
1035
+ * Replace every Markdown link to `href` with its link text, leaving fenced
1036
+ * code and comments untouched. Reference definitions and HTML attributes are
1037
+ * left alone: a definition is harmless, and an `href` inside a component is
1038
+ * the author's to fix.
1039
+ */
1040
+ export function unlinkTarget(content, href) {
1041
+ const escaped = href.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1042
+ const markdown = new RegExp(`\\[([^\\]]*)\\]\\(<?${escaped}>?(?:\\s+["'][^"']*["'])?\\)`, 'g');
1043
+ const protectedBlocks = /```[^\n]*\n[\s\S]*?```|~~~[^\n]*\n[\s\S]*?~~~|<!--[\s\S]*?-->|`[^`\n]*`/g;
1044
+ let output = '';
1045
+ let cursor = 0;
1046
+ for (const match of content.matchAll(protectedBlocks)) {
1047
+ output += content.slice(cursor, match.index).replace(markdown, '$1');
1048
+ output += match[0];
1049
+ cursor = match.index + match[0].length;
1050
+ }
1051
+ output += content.slice(cursor).replace(markdown, '$1');
1052
+ return output;
1053
+ }
1054
+ function isLocalPageLink(href) {
1055
+ return !(href.startsWith('#') || href.startsWith('//') || /^[a-z][a-z0-9+.-]*:/i.test(href));
1056
+ }
1057
+ function splitSuffix(href) {
1058
+ const index = href.search(/[?#]/);
1059
+ return index === -1 ? [href, ''] : [href.slice(0, index), href.slice(index)];
1060
+ }
1061
+ function isAssetPath(path) {
1062
+ const extension = extname(path).toLowerCase();
1063
+ return extension !== '' && !LINK_EXTENSIONS.includes(extension);
1064
+ }
1065
+ /** Mirrors the candidate list validation uses for local links. */
1066
+ async function linkResolves(decoded, pagePath, contentRoot) {
1067
+ const base = decoded.startsWith('/') ? resolve(contentRoot, `.${decoded}`) : resolve(dirname(pagePath), decoded);
1068
+ const candidates = extname(base) === ''
1069
+ ? [base, ...['md', 'mdx', 'rst', 'html', 'htm'].flatMap((extension) => [`${base}.${extension}`, join(base, `index.${extension}`)])]
1070
+ : /\.html?$/.test(base)
1071
+ ? [base, base.replace(/\.html?$/, '.rst'), base.replace(/\.html?$/, '.md'), base.replace(/\.html?$/, '.mdx')]
1072
+ : [base];
1073
+ for (const candidate of candidates) {
1074
+ if (!isInside(contentRoot, candidate))
1075
+ continue;
1076
+ try {
1077
+ await access(candidate);
1078
+ return true;
1079
+ }
1080
+ catch {
1081
+ // Try the next supported form.
1082
+ }
1083
+ }
1084
+ return false;
1085
+ }
1086
+ /**
1087
+ * Replace one link target everywhere it appears as a Markdown destination, a
1088
+ * reference definition, or an `href`/`src` attribute, leaving fenced code and
1089
+ * comments untouched so examples keep their literal text.
1090
+ */
1091
+ export function rewriteLink(content, from, to) {
1092
+ const escaped = from.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1093
+ const markdown = new RegExp(`(\\]\\(<?)${escaped}(>?(?:\\s+["'][^"']*["'])?\\))`, 'g');
1094
+ const reference = new RegExp(`(^\\s{0,3}\\[[^\\]]+\\]:\\s*<?)${escaped}(>?)(?=\\s|$)`, 'gm');
1095
+ const attribute = new RegExp(`(\\b(?:href|src)\\s*=\\s*)(["'])${escaped}\\2`, 'gi');
1096
+ const protectedBlocks = /```[^\n]*\n[\s\S]*?```|~~~[^\n]*\n[\s\S]*?~~~|<!--[\s\S]*?-->/g;
1097
+ let output = '';
1098
+ let cursor = 0;
1099
+ const apply = (segment) => segment
1100
+ .replace(markdown, (_match, open, close) => `${open}${to}${close}`)
1101
+ .replace(reference, (_match, open, close) => `${open}${to}${close}`)
1102
+ .replace(attribute, (_match, prefix, quote) => `${prefix}${quote}${to}${quote}`);
1103
+ for (const match of content.matchAll(protectedBlocks)) {
1104
+ output += apply(content.slice(cursor, match.index));
1105
+ output += match[0];
1106
+ cursor = match.index + match[0].length;
1107
+ }
1108
+ output += apply(content.slice(cursor));
1109
+ return output;
1110
+ }
1111
+ // ---------------------------------------------------------------------------
1112
+ // Evidence map
1113
+ async function repairEvidenceMap(root, contentRoot, plan, existing, extensions, report) {
1114
+ let map;
1115
+ let rebuilt = false;
1116
+ try {
1117
+ map = await readEvidenceMap(root);
1118
+ }
1119
+ catch (error) {
1120
+ const invalidFile = join('.doxloop', 'evidence-map.invalid.json');
1121
+ await rename(join(root, EVIDENCE_MAP_FILE), join(root, invalidFile));
1122
+ map = { schemaVersion: 1, pages: {} };
1123
+ rebuilt = true;
1124
+ for (const page of plan.pages) {
1125
+ const id = normalizePlanPath(page.path);
1126
+ if (!id || !isInside(contentRoot, resolve(contentRoot, id)))
1127
+ continue;
1128
+ const absolute = await findPageFile(contentRoot, id, extensions);
1129
+ if (absolute)
1130
+ map.pages[relativePath(root, absolute)] = seedEvidence(page);
1131
+ }
1132
+ report.problems.push(`${EVIDENCE_MAP_FILE} was malformed (${errorMessage(error)}); it was moved to ${invalidFile} and rebuilt from the plan with inferred confidence. Review the rebuilt entries.`);
1133
+ }
1134
+ if (existing.length === 0 && !rebuilt)
1135
+ return;
1136
+ const next = map ?? { schemaVersion: 1, pages: {} };
1137
+ const added = [];
1138
+ for (const entry of existing) {
1139
+ if (next.pages[entry.file])
1140
+ continue;
1141
+ next.pages[entry.file] = seedEvidence(entry.page);
1142
+ added.push(entry.file);
1143
+ if (entry.page.evidenceDetails.length === 0) {
1144
+ report.problems.push(`${entry.file}: the plan lists no evidence for this page, so its evidence-map entry has no sources and cannot go stale.`);
1145
+ }
1146
+ }
1147
+ if (added.length === 0 && !rebuilt)
1148
+ return;
1149
+ await writeEvidenceMap(root, next);
1150
+ for (const file of added)
1151
+ report.repairs.push(`${EVIDENCE_MAP_FILE}: seeded an inferred entry for ${file} from the plan.`);
1152
+ }
1153
+ export function seedEvidence(page) {
1154
+ const grouped = new Map();
1155
+ for (const detail of page.evidenceDetails ?? []) {
1156
+ const source = detail.source?.trim();
1157
+ if (!source)
1158
+ continue;
1159
+ const paths = grouped.get(source) ?? new Set();
1160
+ const path = detail.path?.trim();
1161
+ if (path)
1162
+ paths.add(path);
1163
+ grouped.set(source, paths);
1164
+ }
1165
+ return {
1166
+ sources: [...grouped].map(([source, paths]) => (paths.size > 0 ? { source, paths: [...paths] } : { source })),
1167
+ confidence: 'inferred',
1168
+ };
1169
+ }
1170
+ // ---------------------------------------------------------------------------
1171
+ // Shared helpers
1172
+ function isDoxbrix(project) {
1173
+ return project.generator === 'doxbrix' || project.generator === undefined;
1174
+ }
1175
+ function pageExtensionsFor(plan) {
1176
+ const configured = (plan.target?.pageExtensions ?? [])
1177
+ .map((extension) => (extension.startsWith('.') ? extension : `.${extension}`).toLowerCase())
1178
+ .filter((extension) => PAGE_EXTENSIONS.includes(extension));
1179
+ return configured.length > 0 ? [...new Set(configured)] : [...PAGE_EXTENSIONS];
1180
+ }
1181
+ /** Strip `./`, a leading slash, and a page extension so plan paths compare to page ids. */
1182
+ export function normalizePlanPath(path) {
1183
+ return path
1184
+ .trim()
1185
+ .replace(/\\/g, '/')
1186
+ .replace(/^(?:\.\/)+/, '')
1187
+ .replace(/^\/+/, '')
1188
+ .replace(/\/+$/, '')
1189
+ .replace(/\.(?:md|mdx)$/i, '');
1190
+ }
1191
+ async function findPageFile(contentRoot, id, extensions) {
1192
+ const candidates = [
1193
+ ...extensions.map((extension) => resolve(contentRoot, `${id}${extension}`)),
1194
+ ...extensions.map((extension) => resolve(contentRoot, id, `index${extension}`)),
1195
+ ];
1196
+ for (const candidate of candidates) {
1197
+ if (await pathExists(candidate))
1198
+ return candidate;
1199
+ }
1200
+ return undefined;
1201
+ }
1202
+ function isInside(root, candidate) {
1203
+ const relation = relative(root, candidate);
1204
+ return relation === '' || (!relation.startsWith('..') && !relation.startsWith('/'));
1205
+ }
1206
+ function isText(value) {
1207
+ return typeof value === 'string' && value.trim() !== '';
1208
+ }
1209
+ function labelFromId(id) {
1210
+ const segment = id.split('/').filter(Boolean).at(-1) ?? id;
1211
+ return segment.replace(/[-_]+/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
1212
+ }
1213
+ function errorMessage(error) {
1214
+ return error instanceof Error ? error.message : String(error);
1215
+ }
1216
+ // ---------------------------------------------------------------------------
1217
+ // Missing images and superseded starters
1218
+ const IMAGE_REFERENCE = /!\[[^\]]*\]\(\s*([^)\s]+)[^)]*\)|<img\b[^>]*\bsrc=["']([^"']+)["']/g;
1219
+ /**
1220
+ * An image a page embeds but the workspace no longer holds (a capture the
1221
+ * screenshot check downgraded, an asset the agent named but never saved) is
1222
+ * a broken link that blocks the whole proposal. The end-of-run pass drops
1223
+ * the embed, with its <Frame> wrapper, so the page stays applicable.
1224
+ */
1225
+ async function removeMissingImages(root, contentRoot, targets, report) {
1226
+ for (const entry of targets) {
1227
+ let content;
1228
+ try {
1229
+ content = await readFile(entry.absolute, 'utf8');
1230
+ }
1231
+ catch {
1232
+ continue;
1233
+ }
1234
+ let next = content;
1235
+ const removed = [];
1236
+ for (const match of content.matchAll(IMAGE_REFERENCE)) {
1237
+ const raw = (match[1] ?? match[2] ?? '').trim();
1238
+ if (!raw || /^[a-z]+:/i.test(raw) || raw.startsWith('//') || raw.startsWith('data:'))
1239
+ continue;
1240
+ const target = raw.split(/[?#]/)[0];
1241
+ let decoded;
1242
+ try {
1243
+ decoded = decodeURIComponent(target);
1244
+ }
1245
+ catch {
1246
+ continue;
1247
+ }
1248
+ const absolute = decoded.startsWith('/') ? resolve(contentRoot, `.${decoded}`) : resolve(dirname(entry.absolute), decoded);
1249
+ if (relative(root, absolute).startsWith('..'))
1250
+ continue;
1251
+ if (await pathExists(absolute))
1252
+ continue;
1253
+ next = removeImageReference(next, decoded);
1254
+ removed.push(decoded);
1255
+ }
1256
+ if (next !== content) {
1257
+ await writeFile(entry.absolute, next, 'utf8');
1258
+ report.repairs.push(`${entry.file}: removed ${removed.length} image embed${removed.length === 1 ? '' : 's'} whose file does not exist (${removed.join(', ')}).`);
1259
+ }
1260
+ }
1261
+ }
1262
+ /**
1263
+ * Delete the generated starter pages behind unplanned-page errors when they
1264
+ * still carry the starter marker. A plan that keeps no page at a starter's
1265
+ * path superseded it; handing it to a fix session produced a rewritten page
1266
+ * that was still in no navigation.
1267
+ */
1268
+ export async function removeSupersededStarterPages(root, issues, report) {
1269
+ const removed = [];
1270
+ for (const file of new Set(issues.filter((issue) => (issue.code === 'unnavigated-page' || issue.code === 'thin-page' || issue.code === 'starter-content') && issue.file).map((issue) => issue.file))) {
1271
+ if (!/^[\w./-]+\.(mdx?|rst)$/.test(file) || file.split('/').includes('..'))
1272
+ continue;
1273
+ const absolute = join(root, file);
1274
+ let content;
1275
+ try {
1276
+ content = await readFile(absolute, 'utf8');
1277
+ }
1278
+ catch {
1279
+ continue;
1280
+ }
1281
+ if (!isStarterContent(content))
1282
+ continue;
1283
+ await rm(absolute, { force: true });
1284
+ removed.push(file);
1285
+ report?.repairs.push(`${file}: removed the starter page the plan superseded.`);
1286
+ }
1287
+ return removed;
1288
+ }
1289
+ //# sourceMappingURL=authoring-postpass.js.map