@doxbrix/doxloop 0.1.4 → 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.
- package/CHANGELOG.md +1153 -0
- package/README.md +422 -226
- package/assets/doxbrix-preview.css +77 -15
- package/contracts/agent-events-v1.schema.json +1 -0
- package/contracts/coverage-v1.schema.json +59 -0
- package/contracts/drift-v1.schema.json +1 -0
- package/contracts/evaluation-v1.schema.json +62 -0
- package/contracts/quality-codes-v1.json +1 -0
- package/contracts/quality-report-v1.schema.json +1 -0
- package/contracts/validation-v1.schema.json +1 -0
- package/dist/agent-failure.d.ts +36 -0
- package/dist/agent-failure.js +98 -0
- package/dist/agent-isolation.d.ts +17 -0
- package/dist/agent-isolation.js +94 -0
- package/dist/agent-log.d.ts +197 -0
- package/dist/agent-log.js +1094 -0
- package/dist/agent-process.d.ts +63 -0
- package/dist/agent-process.js +128 -0
- package/dist/agent-reply.d.ts +47 -0
- package/dist/agent-reply.js +315 -0
- package/dist/agents.d.ts +40 -4
- package/dist/agents.js +115 -11
- package/dist/api-coverage.d.ts +11 -0
- package/dist/api-coverage.js +72 -0
- package/dist/application-probe.d.ts +28 -0
- package/dist/application-probe.js +93 -0
- package/dist/application-url.d.ts +14 -0
- package/dist/application-url.js +26 -0
- package/dist/args.js +15 -0
- package/dist/artifact-deploy.d.ts +9 -0
- package/dist/artifact-deploy.js +28 -8
- package/dist/assets.d.ts +72 -0
- package/dist/assets.js +382 -0
- package/dist/auth.d.ts +10 -0
- package/dist/auth.js +38 -9
- package/dist/author.d.ts +206 -4
- package/dist/author.js +1650 -52
- package/dist/authoring-batches.d.ts +190 -0
- package/dist/authoring-batches.js +489 -0
- package/dist/authoring-postpass.d.ts +79 -0
- package/dist/authoring-postpass.js +1289 -0
- package/dist/authoring-progress.d.ts +95 -0
- package/dist/authoring-progress.js +269 -0
- package/dist/autosync.d.ts +56 -0
- package/dist/autosync.js +442 -0
- package/dist/batch-artifacts.d.ts +81 -0
- package/dist/batch-artifacts.js +412 -0
- package/dist/batch-limits.d.ts +37 -0
- package/dist/batch-limits.js +80 -0
- package/dist/branding.d.ts +41 -0
- package/dist/branding.js +229 -0
- package/dist/bundle-upload.d.ts +26 -0
- package/dist/bundle-upload.js +99 -0
- package/dist/capture-auth.d.ts +95 -0
- package/dist/capture-auth.js +204 -0
- package/dist/capture.d.ts +48 -0
- package/dist/capture.js +20 -1
- package/dist/cli.js +839 -32
- package/dist/content-links.d.ts +10 -0
- package/dist/content-links.js +49 -0
- package/dist/contract-validation.d.ts +4 -0
- package/dist/contract-validation.js +25 -0
- package/dist/coverage-actions.d.ts +8 -0
- package/dist/coverage-actions.js +71 -0
- package/dist/coverage-resolutions.d.ts +16 -0
- package/dist/coverage-resolutions.js +58 -0
- package/dist/db.d.ts +37 -0
- package/dist/db.js +288 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +122 -0
- package/dist/deploy-credentials.d.ts +7 -0
- package/dist/deploy-credentials.js +76 -0
- package/dist/deploy-targets/github-pages.d.ts +3 -0
- package/dist/deploy-targets/github-pages.js +100 -0
- package/dist/deploy-targets/index.d.ts +8 -0
- package/dist/deploy-targets/index.js +78 -0
- package/dist/deploy-targets/netlify.d.ts +3 -0
- package/dist/deploy-targets/netlify.js +49 -0
- package/dist/deploy-targets/types.d.ts +32 -0
- package/dist/deploy-targets/types.js +2 -0
- package/dist/deploy-targets/vercel.d.ts +3 -0
- package/dist/deploy-targets/vercel.js +95 -0
- package/dist/deploy.d.ts +9 -0
- package/dist/deploy.js +87 -13
- package/dist/deterministic-capture.d.ts +90 -0
- package/dist/deterministic-capture.js +435 -0
- package/dist/direct-edit.d.ts +40 -0
- package/dist/direct-edit.js +159 -0
- package/dist/docs-crawl.d.ts +86 -0
- package/dist/docs-crawl.js +536 -0
- package/dist/docs-site.d.ts +49 -0
- package/dist/docs-site.js +233 -0
- package/dist/doctor.js +8 -0
- package/dist/documentation-collections.d.ts +23 -0
- package/dist/documentation-collections.js +191 -0
- package/dist/documentation-plan.d.ts +253 -0
- package/dist/documentation-plan.js +2563 -0
- package/dist/doxbrix-build.d.ts +19 -0
- package/dist/doxbrix-build.js +167 -0
- package/dist/doxbrix-markdown.d.ts +9 -0
- package/dist/doxbrix-markdown.js +115 -14
- package/dist/drift.d.ts +10 -0
- package/dist/drift.js +164 -0
- package/dist/evaluation.d.ts +46 -0
- package/dist/evaluation.js +113 -0
- package/dist/evidence-pack.d.ts +47 -0
- package/dist/evidence-pack.js +358 -0
- package/dist/evidence.d.ts +25 -0
- package/dist/evidence.js +175 -0
- package/dist/fs.d.ts +8 -2
- package/dist/fs.js +41 -11
- package/dist/generator-api.d.ts +89 -0
- package/dist/generator-preflight.d.ts +27 -0
- package/dist/generator-preflight.js +105 -0
- package/dist/generator-runtime.d.ts +7 -0
- package/dist/generator-runtime.js +17 -1
- package/dist/generators.d.ts +24 -2
- package/dist/generators.js +54 -1
- package/dist/git-delivery.d.ts +17 -0
- package/dist/git-delivery.js +123 -0
- package/dist/globs.d.ts +16 -0
- package/dist/globs.js +65 -0
- package/dist/glossary.d.ts +26 -0
- package/dist/glossary.js +179 -0
- package/dist/history.d.ts +106 -0
- package/dist/history.js +600 -0
- package/dist/html-markdown.d.ts +46 -0
- package/dist/html-markdown.js +423 -0
- package/dist/interactive.js +16 -15
- package/dist/job-events.d.ts +74 -0
- package/dist/job-events.js +377 -0
- package/dist/keep-awake.d.ts +50 -0
- package/dist/keep-awake.js +123 -0
- package/dist/local-source-snapshot.d.ts +20 -0
- package/dist/local-source-snapshot.js +61 -0
- package/dist/mintlify-detect.d.ts +3 -0
- package/dist/mintlify-detect.js +18 -0
- package/dist/mintlify-import.d.ts +75 -0
- package/dist/mintlify-import.js +190 -0
- package/dist/navigation.d.ts +98 -0
- package/dist/navigation.js +310 -0
- package/dist/openapi.d.ts +60 -0
- package/dist/openapi.js +439 -0
- package/dist/page-editor-bridge.d.ts +3 -0
- package/dist/page-editor-bridge.js +109 -0
- package/dist/page-editor-preview.d.ts +10 -0
- package/dist/page-editor-preview.js +55 -0
- package/dist/page-extension.d.ts +9 -0
- package/dist/page-extension.js +15 -0
- package/dist/page-metadata.d.ts +28 -0
- package/dist/page-metadata.js +166 -0
- package/dist/page-operations.d.ts +34 -0
- package/dist/page-operations.js +215 -0
- package/dist/page-routes.d.ts +4 -0
- package/dist/page-routes.js +61 -0
- package/dist/pages.d.ts +20 -0
- package/dist/pages.js +184 -0
- package/dist/plan-generator.d.ts +3 -0
- package/dist/plan-generator.js +21 -0
- package/dist/plan-navigation.d.ts +11 -0
- package/dist/plan-navigation.js +30 -0
- package/dist/planning-captures.d.ts +20 -0
- package/dist/planning-captures.js +143 -0
- package/dist/planning-research.d.ts +135 -0
- package/dist/planning-research.js +472 -0
- package/dist/planning-triage.d.ts +23 -0
- package/dist/planning-triage.js +131 -0
- package/dist/preview.d.ts +24 -0
- package/dist/preview.js +280 -29
- package/dist/project-detect.d.ts +36 -0
- package/dist/project-detect.js +251 -0
- package/dist/project-import.d.ts +54 -0
- package/dist/project-import.js +157 -0
- package/dist/project-lock.d.ts +6 -0
- package/dist/project-lock.js +96 -0
- package/dist/project-registry.d.ts +25 -0
- package/dist/project-registry.js +79 -0
- package/dist/project.d.ts +35 -3
- package/dist/project.js +388 -38
- package/dist/prompts.d.ts +9 -0
- package/dist/prompts.js +33 -4
- package/dist/proposal-replay.d.ts +32 -0
- package/dist/proposal-replay.js +99 -0
- package/dist/quality-claims.d.ts +8 -0
- package/dist/quality-claims.js +168 -0
- package/dist/quality-config.d.ts +5 -0
- package/dist/quality-config.js +84 -0
- package/dist/quality-contract.d.ts +37 -0
- package/dist/quality-contract.js +46 -0
- package/dist/quality-examples.d.ts +4 -0
- package/dist/quality-examples.js +233 -0
- package/dist/quality-gates.d.ts +16 -0
- package/dist/quality-gates.js +192 -0
- package/dist/quality-links.d.ts +7 -0
- package/dist/quality-links.js +149 -0
- package/dist/quality-lint.d.ts +6 -0
- package/dist/quality-lint.js +124 -0
- package/dist/quality-rendered.d.ts +11 -0
- package/dist/quality-rendered.js +222 -0
- package/dist/quality-schema.d.ts +3 -0
- package/dist/quality-schema.js +71 -0
- package/dist/release-notes.d.ts +44 -0
- package/dist/release-notes.js +183 -0
- package/dist/remote-monitor.d.ts +16 -0
- package/dist/remote-monitor.js +74 -0
- package/dist/remote-source.d.ts +34 -0
- package/dist/remote-source.js +426 -0
- package/dist/review-diff.d.ts +82 -0
- package/dist/review-diff.js +400 -0
- package/dist/review-learning.d.ts +11 -0
- package/dist/review-learning.js +60 -0
- package/dist/review-render.d.ts +38 -0
- package/dist/review-render.js +224 -0
- package/dist/review-report.d.ts +9 -0
- package/dist/review-report.js +89 -0
- package/dist/review-ui.d.ts +14 -0
- package/dist/review-ui.js +1248 -0
- package/dist/schedule.d.ts +78 -0
- package/dist/schedule.js +480 -0
- package/dist/screen-capture-provider.d.ts +52 -0
- package/dist/screen-capture-provider.js +218 -0
- package/dist/screenshot-workflow.d.ts +167 -0
- package/dist/screenshot-workflow.js +1237 -0
- package/dist/settings.d.ts +1 -1
- package/dist/settings.js +95 -7
- package/dist/site-export.d.ts +18 -0
- package/dist/site-export.js +87 -0
- package/dist/source-connectors.d.ts +33 -0
- package/dist/source-connectors.js +268 -0
- package/dist/source-discovery.d.ts +132 -0
- package/dist/source-discovery.js +823 -0
- package/dist/source-intelligence.d.ts +9 -0
- package/dist/source-intelligence.js +306 -0
- package/dist/sync-review.d.ts +28 -0
- package/dist/sync-review.js +264 -0
- package/dist/sync-runs.d.ts +192 -0
- package/dist/sync-runs.js +2244 -0
- package/dist/sync.d.ts +35 -0
- package/dist/sync.js +298 -32
- package/dist/text-diff.d.ts +9 -0
- package/dist/text-diff.js +59 -0
- package/dist/types.d.ts +946 -1
- package/dist/ui/assets/doxloop-logo-light-De7Nx7j7.png +0 -0
- package/dist/ui/assets/index-BHBYU2aG.css +1 -0
- package/dist/ui/assets/index-Cq3RPQiC.js +33 -0
- package/dist/ui/index.html +18 -0
- package/dist/ui-server.d.ts +83 -0
- package/dist/ui-server.js +3532 -0
- package/dist/usage-budget.d.ts +28 -0
- package/dist/usage-budget.js +90 -0
- package/dist/validation.d.ts +25 -1
- package/dist/validation.js +312 -26
- package/dist/workspace-tools.d.ts +54 -0
- package/dist/workspace-tools.js +123 -0
- package/docs/agent-compatibility.md +50 -28
- package/docs/ci-and-automation.md +105 -66
- package/docs/doxbrix-http-api.md +8 -1
- package/docs/existing-documentation.md +80 -0
- package/docs/generation-performance.md +108 -0
- package/docs/generator-authoring.md +68 -5
- package/docs/generator-selection.md +50 -13
- package/docs/mintlify-import.md +71 -0
- package/docs/openapi-security.md +25 -0
- package/docs/project-format.md +331 -36
- package/docs/release-quality.md +158 -0
- package/docs/releasing.md +72 -0
- package/docs/review-workflows.md +51 -0
- package/docs/security-model.md +102 -39
- package/docs/troubleshooting.md +226 -93
- package/package.json +46 -15
- package/scripts/test-auto-screenshot.mjs +172 -0
- package/skills/doxloop-authoring/SKILL.md +242 -361
- package/skills/doxloop-authoring/references/existing-documentation.md +94 -0
- package/skills/doxloop-authoring/references/navigation-architecture.md +18 -7
- package/skills/doxloop-authoring/references/page-depth.md +169 -0
- package/skills/doxloop-authoring/references/project-format.md +106 -7
- package/skills/doxloop-authoring/references/quality.md +10 -0
- package/skills/doxloop-authoring/references/screenshot-manifest.md +113 -0
- package/skills/doxloop-authoring/references/screenshots.md +149 -235
- package/skills/doxloop-authoring/references/workflows.md +76 -0
- package/skills/doxloop-doxbrix/SKILL.md +38 -22
- package/skills/doxloop-doxbrix/references/api-endpoints.md +15 -14
- package/skills/doxloop-doxbrix/references/components.md +30 -3
- package/skills/doxloop-doxbrix/references/manifest.md +3 -2
- package/vendor/doxbrix-import/LICENSE +202 -0
- package/vendor/doxbrix-import/README.md +17 -0
- package/vendor/doxbrix-import/UPSTREAM.json +47 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.d.ts +13 -0
- package/vendor/doxbrix-import/dist/docs/frontmatter.js +83 -0
- package/vendor/doxbrix-import/dist/docs/import.d.ts +21 -0
- package/vendor/doxbrix-import/dist/docs/import.js +147 -0
- package/vendor/doxbrix-import/dist/docs/manifest.d.ts +163 -0
- package/vendor/doxbrix-import/dist/docs/manifest.js +64 -0
- package/vendor/doxbrix-import/dist/docs/project.d.ts +25 -0
- package/vendor/doxbrix-import/dist/docs/project.js +77 -0
- package/vendor/doxbrix-import/dist/docs/starter.d.ts +4 -0
- package/vendor/doxbrix-import/dist/docs/starter.js +11 -0
- package/vendor/doxbrix-import/dist/importer.d.ts +227 -0
- package/vendor/doxbrix-import/dist/importer.js +1567 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.d.ts +37 -0
- package/vendor/doxbrix-import/dist/mintlify-openapi.js +305 -0
- package/vendor/doxbrix-import/dist/safe-path.d.ts +9 -0
- package/vendor/doxbrix-import/dist/safe-path.js +47 -0
- package/dist/agents.d.ts.map +0 -1
- package/dist/agents.js.map +0 -1
- package/dist/args.d.ts.map +0 -1
- package/dist/args.js.map +0 -1
- package/dist/artifact-deploy.d.ts.map +0 -1
- package/dist/artifact-deploy.js.map +0 -1
- package/dist/auth.d.ts.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/author.d.ts.map +0 -1
- package/dist/author.js.map +0 -1
- package/dist/capture.d.ts.map +0 -1
- package/dist/capture.js.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js.map +0 -1
- package/dist/deploy.d.ts.map +0 -1
- package/dist/deploy.js.map +0 -1
- package/dist/deployment-visibility.d.ts.map +0 -1
- package/dist/deployment-visibility.js.map +0 -1
- package/dist/doctor.d.ts.map +0 -1
- package/dist/doctor.js.map +0 -1
- package/dist/doxbrix-markdown.d.ts.map +0 -1
- package/dist/doxbrix-markdown.js.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/errors.js.map +0 -1
- package/dist/fs.d.ts.map +0 -1
- package/dist/fs.js.map +0 -1
- package/dist/generator-api.d.ts.map +0 -1
- package/dist/generator-api.js.map +0 -1
- package/dist/generator-manager.d.ts.map +0 -1
- package/dist/generator-manager.js.map +0 -1
- package/dist/generator-runtime.d.ts.map +0 -1
- package/dist/generator-runtime.js.map +0 -1
- package/dist/generators.d.ts.map +0 -1
- package/dist/generators.js.map +0 -1
- package/dist/interactive.d.ts.map +0 -1
- package/dist/interactive.js.map +0 -1
- package/dist/preview.d.ts.map +0 -1
- package/dist/preview.js.map +0 -1
- package/dist/progress.d.ts.map +0 -1
- package/dist/progress.js.map +0 -1
- package/dist/project.d.ts.map +0 -1
- package/dist/project.js.map +0 -1
- package/dist/prompts.d.ts.map +0 -1
- package/dist/prompts.js.map +0 -1
- package/dist/settings.d.ts.map +0 -1
- package/dist/settings.js.map +0 -1
- package/dist/sync.d.ts.map +0 -1
- package/dist/sync.js.map +0 -1
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js.map +0 -1
- package/dist/validation.d.ts.map +0 -1
- package/dist/validation.js.map +0 -1
- package/dist/version.d.ts.map +0 -1
- 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
|