@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,55 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import matter from 'gray-matter';
|
|
3
|
+
import { renderEditableMarkdown } from './doxbrix-markdown.js';
|
|
4
|
+
import { loadProject, loadSiteConfig } from './project.js';
|
|
5
|
+
import { readPageContent } from './page-operations.js';
|
|
6
|
+
import { doxbrixDocument } from './preview.js';
|
|
7
|
+
import { DoxloopError } from './errors.js';
|
|
8
|
+
import { installPageEditorBridge } from './page-editor-bridge.js';
|
|
9
|
+
/** A sandboxed native rendering of a draft; this endpoint never writes a file. */
|
|
10
|
+
export async function pageEditorPreview(root, path, content, base) {
|
|
11
|
+
await readPageContent(root, path);
|
|
12
|
+
const project = await loadProject(root);
|
|
13
|
+
if (project.generator !== 'doxbrix')
|
|
14
|
+
throw new DoxloopError('Use source editing for this generator, then check the native preview.');
|
|
15
|
+
if (Buffer.byteLength(content) > 2_000_000)
|
|
16
|
+
throw new DoxloopError('The page is too large for visual editing. Use source editing.');
|
|
17
|
+
const url = new URL(base);
|
|
18
|
+
if (!['http:', 'https:'].includes(url.protocol) || !['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) || url.username || url.password)
|
|
19
|
+
throw new DoxloopError('Visual editing requires a local preview URL.');
|
|
20
|
+
const parsed = matter(content);
|
|
21
|
+
const offset = content.length - parsed.content.length;
|
|
22
|
+
const rendered = renderEditableMarkdown(parsed.content);
|
|
23
|
+
const blocks = rendered.blocks.map((block) => ({ ...block, start: block.start + offset, end: block.end + offset }));
|
|
24
|
+
rendered.html = rendered.html.replace(/data-edit-start="(\d+)" data-edit-end="(\d+)"/g, (_, start, end) => `data-edit-start="${Number(start) + offset}" data-edit-end="${Number(end) + offset}"`);
|
|
25
|
+
const site = await loadSiteConfig(root, project);
|
|
26
|
+
let html = doxbrixDocument({ site, title: String(parsed.data.title ?? path.split('/').at(-1)), ...(typeof parsed.data.description === 'string' ? { description: parsed.data.description } : {}), current: url.pathname.replace(/^\/+|\/+$/g, ''), rendered, embedded: true, liveReload: false });
|
|
27
|
+
// Header fields are rendered outside the Markdown body. Edit their YAML values
|
|
28
|
+
// as quoted scalars so punctuation such as colons cannot change the document shape.
|
|
29
|
+
const frontmatter = content.slice(0, offset);
|
|
30
|
+
for (const [field, className] of [['title', 'dp-page-title'], ['description', 'dxb-atlas-description']]) {
|
|
31
|
+
if (typeof parsed.data[field] !== 'string')
|
|
32
|
+
continue;
|
|
33
|
+
const match = new RegExp(`^${field}:[ \t]*([^\\r\\n]+)`, 'm').exec(frontmatter);
|
|
34
|
+
if (!match || /^[>|]/.test(match[1].trim()))
|
|
35
|
+
continue;
|
|
36
|
+
const text = match[1];
|
|
37
|
+
const start = match.index + match[0].length - text.length;
|
|
38
|
+
// Only scalar values that round-trip to the displayed field may be replaced.
|
|
39
|
+
if (matter(`---\nvalue: ${text}\n---\n`).data.value !== parsed.data[field])
|
|
40
|
+
continue;
|
|
41
|
+
blocks.push({ start, end: start + text.length, text });
|
|
42
|
+
const source = text.replaceAll('&', '&').replaceAll('"', '"').replaceAll('<', '<');
|
|
43
|
+
html = html.replace(`class="${className}"`, `class="${className}" data-edit-start="${start}" data-edit-end="${start + text.length}" data-edit-format="yaml" data-edit-source="${source}"`);
|
|
44
|
+
}
|
|
45
|
+
const css = await readFile(new URL('../assets/doxbrix-preview.css', import.meta.url), 'utf8');
|
|
46
|
+
html = html.replace('<head>', `<head><base href="${url.href.replaceAll('&', '&').replaceAll('"', '"')}">`)
|
|
47
|
+
.replace('<link rel="stylesheet" href="/__doxloop/doxbrix.css">', `<style>${css}</style>`)
|
|
48
|
+
.replace('</body>', `<style>
|
|
49
|
+
[data-edit-start]{outline:1px dashed var(--project-primary);outline-offset:4px;border-radius:3px;cursor:text;white-space:pre-wrap}
|
|
50
|
+
[data-edit-start]:focus{outline:2px solid var(--project-primary)}
|
|
51
|
+
.dp-edit-page{display:none}
|
|
52
|
+
</style><script>(${installPageEditorBridge.toString()})()</script></body>`);
|
|
53
|
+
return { html, blocks };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=page-editor-preview.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { DocumentationPlanTarget } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The one file extension new pages are written with. Doxbrix reads `.md` and
|
|
4
|
+
* `.mdx` alike, so its plan target lists both, but a site whose pages switch
|
|
5
|
+
* between the two by batch reads as unfinished and its components only
|
|
6
|
+
* belong in `.mdx`. Other generators write their first listed extension.
|
|
7
|
+
*/
|
|
8
|
+
export declare function preferredPageExtension(target: Partial<Pick<DocumentationPlanTarget, 'generator' | 'pageExtensions'>> | undefined): string;
|
|
9
|
+
//# sourceMappingURL=page-extension.d.ts.map
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one file extension new pages are written with. Doxbrix reads `.md` and
|
|
3
|
+
* `.mdx` alike, so its plan target lists both, but a site whose pages switch
|
|
4
|
+
* between the two by batch reads as unfinished and its components only
|
|
5
|
+
* belong in `.mdx`. Other generators write their first listed extension.
|
|
6
|
+
*/
|
|
7
|
+
export function preferredPageExtension(target) {
|
|
8
|
+
const listed = (target?.pageExtensions ?? []).map((extension) => (extension.startsWith('.') ? extension : `.${extension}`).toLowerCase());
|
|
9
|
+
if (target?.generator === 'doxbrix' || target?.generator === undefined) {
|
|
10
|
+
if (listed.length === 0 || listed.includes('.mdx'))
|
|
11
|
+
return '.mdx';
|
|
12
|
+
}
|
|
13
|
+
return listed[0] ?? '.mdx';
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=page-extension.js.map
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The frontmatter fields a person edits without an agent: the title and
|
|
3
|
+
* description every generator needs, plus the SEO fields the Doxbrix preview
|
|
4
|
+
* and static build render (`canonical`, `socialImage`) and the sidebar icon.
|
|
5
|
+
* Other frontmatter keys are preserved untouched.
|
|
6
|
+
*/
|
|
7
|
+
export declare const PAGE_METADATA_FIELDS: readonly ["title", "description", "canonical", "socialImage", "icon"];
|
|
8
|
+
export type PageMetadataField = typeof PAGE_METADATA_FIELDS[number];
|
|
9
|
+
export interface PageMetadata {
|
|
10
|
+
path: string;
|
|
11
|
+
/** False for reStructuredText and HTML pages, whose metadata has no frontmatter. */
|
|
12
|
+
editable: boolean;
|
|
13
|
+
reason?: string;
|
|
14
|
+
fingerprint: string;
|
|
15
|
+
fields: Partial<Record<PageMetadataField, string>>;
|
|
16
|
+
/** Frontmatter keys Doxloop leaves alone. */
|
|
17
|
+
otherKeys: string[];
|
|
18
|
+
}
|
|
19
|
+
export declare function readPageMetadata(root: string, rawPath: unknown): Promise<PageMetadata>;
|
|
20
|
+
export interface PageMetadataWrite {
|
|
21
|
+
path: string;
|
|
22
|
+
fingerprint: string;
|
|
23
|
+
/** A string sets the field; `null` or an empty string clears it. Title and description cannot be cleared. */
|
|
24
|
+
fields: Partial<Record<PageMetadataField, string | null>>;
|
|
25
|
+
}
|
|
26
|
+
export declare function updatePageMetadata(root: string, raw: unknown): Promise<PageMetadata>;
|
|
27
|
+
export declare function updateBulkPageMetadata(root: string, writes: unknown[]): Promise<import("./direct-edit.js").DirectEditResult>;
|
|
28
|
+
//# sourceMappingURL=page-metadata.d.ts.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, writeFile, stat } from 'node:fs/promises';
|
|
3
|
+
import { extname, join, resolve } from 'node:path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { applyDirectEdit, safePath } from './direct-edit.js';
|
|
6
|
+
import { DoxloopError } from './errors.js';
|
|
7
|
+
import { assertInside, pathExists, resolveContainedDirectory } from './fs.js';
|
|
8
|
+
import { loadGeneratorAdapter } from './generators.js';
|
|
9
|
+
import { resolveEditScope } from './pages.js';
|
|
10
|
+
import { loadProject } from './project.js';
|
|
11
|
+
/**
|
|
12
|
+
* The frontmatter fields a person edits without an agent: the title and
|
|
13
|
+
* description every generator needs, plus the SEO fields the Doxbrix preview
|
|
14
|
+
* and static build render (`canonical`, `socialImage`) and the sidebar icon.
|
|
15
|
+
* Other frontmatter keys are preserved untouched.
|
|
16
|
+
*/
|
|
17
|
+
export const PAGE_METADATA_FIELDS = ['title', 'description', 'canonical', 'socialImage', 'icon'];
|
|
18
|
+
export async function readPageMetadata(root, rawPath) {
|
|
19
|
+
const path = requirePath(rawPath);
|
|
20
|
+
const project = await loadProject(root);
|
|
21
|
+
await resolveEditScope(root, project, [path], false);
|
|
22
|
+
const absolute = assertInside(root, resolve(root, path));
|
|
23
|
+
const content = await readFile(absolute, 'utf8');
|
|
24
|
+
const fingerprint = createHash('sha256').update(content).digest('hex');
|
|
25
|
+
const format = project.generator === 'doxbrix' ? 'markdown' : (await loadGeneratorAdapter(root, project)).project.contentFormat ?? 'markdown';
|
|
26
|
+
if (format !== 'markdown') {
|
|
27
|
+
return {
|
|
28
|
+
path,
|
|
29
|
+
editable: false,
|
|
30
|
+
reason: `${format === 'rst' ? 'reStructuredText' : 'HTML'} pages keep their title and description in the page body, so edit them there.`,
|
|
31
|
+
fingerprint,
|
|
32
|
+
fields: {},
|
|
33
|
+
otherKeys: [],
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const parsed = matter(content);
|
|
37
|
+
const fields = {};
|
|
38
|
+
for (const field of PAGE_METADATA_FIELDS) {
|
|
39
|
+
const value = parsed.data[field];
|
|
40
|
+
if (typeof value === 'string' && value.trim())
|
|
41
|
+
fields[field] = value.trim();
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
path,
|
|
45
|
+
editable: true,
|
|
46
|
+
fingerprint,
|
|
47
|
+
fields,
|
|
48
|
+
otherKeys: Object.keys(parsed.data).filter((key) => !PAGE_METADATA_FIELDS.includes(key)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function preparePageMetadata(root, raw) {
|
|
52
|
+
const input = parseWrite(raw);
|
|
53
|
+
const current = await readPageMetadata(root, input.path);
|
|
54
|
+
if (!current.editable)
|
|
55
|
+
throw new DoxloopError(current.reason ?? 'This page has no editable metadata.', 2);
|
|
56
|
+
if (current.fingerprint !== input.fingerprint) {
|
|
57
|
+
throw new DoxloopError('The page changed on disk since its metadata was loaded. Reload the page and apply your changes again.');
|
|
58
|
+
}
|
|
59
|
+
const project = await loadProject(root);
|
|
60
|
+
const contentRoot = await resolveContainedDirectory(root, project.contentDir, 'Documentation content directory', {
|
|
61
|
+
allowRoot: project.generator === 'doxbrix',
|
|
62
|
+
});
|
|
63
|
+
const absolute = assertInside(root, resolve(root, input.path));
|
|
64
|
+
const parsed = matter(await readFile(absolute, 'utf8'));
|
|
65
|
+
const data = { ...parsed.data };
|
|
66
|
+
for (const [field, value] of Object.entries(input.fields)) {
|
|
67
|
+
if (value === null || value.trim() === '') {
|
|
68
|
+
if (field === 'title' || field === 'description')
|
|
69
|
+
throw new DoxloopError(`${field === 'title' ? 'Title' : 'Description'} cannot be empty.`, 2);
|
|
70
|
+
delete data[field];
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
data[field] = await validateField(field, value.trim(), contentRoot, root, absolute);
|
|
74
|
+
}
|
|
75
|
+
const nextContent = matter.stringify(parsed.content, data);
|
|
76
|
+
return { path: input.path, apply: async () => {
|
|
77
|
+
if (createHash('sha256').update(await readFile(await safePath(root, input.path))).digest('hex') !== input.fingerprint)
|
|
78
|
+
throw new DoxloopError('The page changed on disk. Reload before saving.');
|
|
79
|
+
await writeFile(await safePath(root, input.path), nextContent, 'utf8');
|
|
80
|
+
} };
|
|
81
|
+
}
|
|
82
|
+
export async function updatePageMetadata(root, raw) {
|
|
83
|
+
const prepared = await preparePageMetadata(root, raw);
|
|
84
|
+
await applyDirectEdit(root, { kind: 'metadata', requestText: `Updated metadata for ${prepared.path}`, files: [prepared.path], pagesChanged: 1, apply: prepared.apply });
|
|
85
|
+
return readPageMetadata(root, prepared.path);
|
|
86
|
+
}
|
|
87
|
+
export async function updateBulkPageMetadata(root, writes) {
|
|
88
|
+
if (!Array.isArray(writes) || writes.length < 1 || writes.length > 500)
|
|
89
|
+
throw new DoxloopError('Select between 1 and 500 pages.');
|
|
90
|
+
const prepared = await Promise.all(writes.map((write) => preparePageMetadata(root, write)));
|
|
91
|
+
if (new Set(prepared.map((item) => item.path)).size !== prepared.length)
|
|
92
|
+
throw new DoxloopError('Select each page once.');
|
|
93
|
+
return applyDirectEdit(root, { kind: 'metadata', requestText: `Updated metadata for ${prepared.length} selected pages`, files: prepared.map((item) => item.path), pagesChanged: prepared.length, apply: async () => { for (const item of prepared)
|
|
94
|
+
await item.apply(); } });
|
|
95
|
+
}
|
|
96
|
+
async function validateField(field, value, contentRoot, root, pagePath) {
|
|
97
|
+
switch (field) {
|
|
98
|
+
case 'title':
|
|
99
|
+
if (value.length > 160)
|
|
100
|
+
throw new DoxloopError('Title must be 160 characters or fewer.', 2);
|
|
101
|
+
return value;
|
|
102
|
+
case 'description':
|
|
103
|
+
if (value.length > 320)
|
|
104
|
+
throw new DoxloopError('Description must be 320 characters or fewer.', 2);
|
|
105
|
+
return value;
|
|
106
|
+
case 'canonical': {
|
|
107
|
+
let url;
|
|
108
|
+
try {
|
|
109
|
+
url = new URL(value);
|
|
110
|
+
}
|
|
111
|
+
catch {
|
|
112
|
+
throw new DoxloopError('Canonical URL must be a complete HTTPS address.', 2);
|
|
113
|
+
}
|
|
114
|
+
if (url.protocol !== 'https:' || url.username || url.password)
|
|
115
|
+
throw new DoxloopError('Canonical URL must be an HTTPS address without credentials.', 2);
|
|
116
|
+
return url.toString();
|
|
117
|
+
}
|
|
118
|
+
case 'socialImage': {
|
|
119
|
+
if (/^https:\/\//i.test(value))
|
|
120
|
+
return value;
|
|
121
|
+
if (!value.startsWith('/') || value.startsWith('//') || value.split('/').some((part) => part === '..')) {
|
|
122
|
+
throw new DoxloopError('Social image must be an HTTPS address or a root-relative path such as /assets/social.png.', 2);
|
|
123
|
+
}
|
|
124
|
+
const project = await loadProject(root);
|
|
125
|
+
const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
|
|
126
|
+
const local = adapter?.resolveLocalAsset?.({ root, contentRoot, pagePath, reference: value }) ?? join(contentRoot, value.slice(1));
|
|
127
|
+
assertInside(root, local);
|
|
128
|
+
await safePath(root, local.slice(root.length + 1));
|
|
129
|
+
if (!(await pathExists(local)) || !(await stat(local)).isFile())
|
|
130
|
+
throw new DoxloopError(`Social image "${value}" is not a file in the documentation folder.`, 2);
|
|
131
|
+
if (!['.png', '.jpg', '.jpeg', '.webp', '.gif'].includes(extname(local).toLowerCase())) {
|
|
132
|
+
throw new DoxloopError('Social image must be a PNG, JPEG, WebP, or GIF file.', 2);
|
|
133
|
+
}
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
case 'icon':
|
|
137
|
+
if (!/^[\p{L}\p{N}\p{Emoji}_-]{1,40}$/u.test(value))
|
|
138
|
+
throw new DoxloopError('Icon must be an icon name or a single emoji.', 2);
|
|
139
|
+
return value;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function parseWrite(raw) {
|
|
143
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
144
|
+
throw new DoxloopError('A metadata write needs a path, fingerprint, and fields.', 2);
|
|
145
|
+
const body = raw;
|
|
146
|
+
if (typeof body.fingerprint !== 'string')
|
|
147
|
+
throw new DoxloopError('A metadata write needs the fingerprint it was loaded with.', 2);
|
|
148
|
+
const fields = {};
|
|
149
|
+
const rawFields = body.fields && typeof body.fields === 'object' && !Array.isArray(body.fields) ? body.fields : {};
|
|
150
|
+
for (const [key, value] of Object.entries(rawFields)) {
|
|
151
|
+
if (!PAGE_METADATA_FIELDS.includes(key))
|
|
152
|
+
throw new DoxloopError(`"${key}" is not an editable metadata field.`, 2);
|
|
153
|
+
if (value !== null && typeof value !== 'string')
|
|
154
|
+
throw new DoxloopError(`${key} must be text.`, 2);
|
|
155
|
+
fields[key] = value;
|
|
156
|
+
}
|
|
157
|
+
if (Object.keys(fields).length === 0)
|
|
158
|
+
throw new DoxloopError('Change at least one metadata field.', 2);
|
|
159
|
+
return { path: requirePath(body.path), fingerprint: body.fingerprint, fields };
|
|
160
|
+
}
|
|
161
|
+
function requirePath(value) {
|
|
162
|
+
if (typeof value !== 'string' || !value.trim())
|
|
163
|
+
throw new DoxloopError('A page metadata request needs a path.', 2);
|
|
164
|
+
return value.trim();
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=page-metadata.js.map
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { type DirectEditResult } from './direct-edit.js';
|
|
2
|
+
export interface PageContent {
|
|
3
|
+
path: string;
|
|
4
|
+
content: string;
|
|
5
|
+
fingerprint: string;
|
|
6
|
+
}
|
|
7
|
+
export interface PageWrite {
|
|
8
|
+
path: string;
|
|
9
|
+
content: string;
|
|
10
|
+
fingerprint: string;
|
|
11
|
+
evidenceDisposition?: 'preserved' | 'needs-review';
|
|
12
|
+
}
|
|
13
|
+
export declare const REDIRECTS_FILE = ".doxloop/redirects.json";
|
|
14
|
+
export declare function readPageContent(root: string, path: string): Promise<PageContent>;
|
|
15
|
+
export declare function savePageContent(root: string, input: PageWrite): Promise<PageContent & DirectEditResult>;
|
|
16
|
+
export declare function previewPageContent(content: string): {
|
|
17
|
+
html: string;
|
|
18
|
+
};
|
|
19
|
+
export interface PageLifecycleInput {
|
|
20
|
+
action: 'create' | 'rename' | 'delete';
|
|
21
|
+
path: string;
|
|
22
|
+
fingerprint?: string;
|
|
23
|
+
to?: string;
|
|
24
|
+
title?: string;
|
|
25
|
+
content?: string;
|
|
26
|
+
replacement?: string;
|
|
27
|
+
}
|
|
28
|
+
/** Pages, navigation, links and evidence are one reversible write. */
|
|
29
|
+
export declare function changePageLifecycle(root: string, input: PageLifecycleInput): Promise<DirectEditResult & {
|
|
30
|
+
path?: string;
|
|
31
|
+
}>;
|
|
32
|
+
export declare function readRedirects(root: string): Promise<Record<string, string>>;
|
|
33
|
+
export declare function rewriteLinks(raw: string, rewrite: (href: string) => string): string;
|
|
34
|
+
//# sourceMappingURL=page-operations.d.ts.map
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, extname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { applyDirectEdit, safePath } from './direct-edit.js';
|
|
6
|
+
import { renderMarkdown } from './doxbrix-markdown.js';
|
|
7
|
+
import { DoxloopError } from './errors.js';
|
|
8
|
+
import { EVIDENCE_MAP_FILE, readEvidenceMap, writeEvidenceMap } from './evidence.js';
|
|
9
|
+
import { pathExists } from './fs.js';
|
|
10
|
+
import { loadGeneratorAdapter } from './generators.js';
|
|
11
|
+
import { appendDoxbrixNavigationPage } from './navigation.js';
|
|
12
|
+
import { resolvePageRoute } from './page-routes.js';
|
|
13
|
+
import { resolveEditScope } from './pages.js';
|
|
14
|
+
import { loadPages, loadProject, loadSiteConfig, pageId, relativePath, siteConfigPath } from './project.js';
|
|
15
|
+
import { withProjectLock } from './project-lock.js';
|
|
16
|
+
export const REDIRECTS_FILE = '.doxloop/redirects.json';
|
|
17
|
+
const fingerprint = (content) => createHash('sha256').update(content).digest('hex');
|
|
18
|
+
export async function readPageContent(root, path) {
|
|
19
|
+
await resolveEditScope(root, await loadProject(root), [path], false);
|
|
20
|
+
const content = await readFile(await safePath(root, path), 'utf8');
|
|
21
|
+
return { path, content, fingerprint: fingerprint(content) };
|
|
22
|
+
}
|
|
23
|
+
export async function savePageContent(root, input) {
|
|
24
|
+
return withProjectLock(root, 'write', async () => {
|
|
25
|
+
checkContent(input.content);
|
|
26
|
+
const current = await readPageContent(root, input.path);
|
|
27
|
+
if (current.fingerprint !== input.fingerprint)
|
|
28
|
+
throw new DoxloopError('The page changed since you opened it. Your draft is kept; reload the saved version and compare before saving.');
|
|
29
|
+
const result = await applyDirectEdit(root, { kind: 'edit', requestText: `Edited ${input.path} directly`, files: [input.path, EVIDENCE_MAP_FILE], pagesChanged: 1, apply: async () => {
|
|
30
|
+
if (fingerprint(await readFile(join(root, input.path), 'utf8')) !== input.fingerprint)
|
|
31
|
+
throw new DoxloopError('The page changed before saving. Reload and compare your draft.');
|
|
32
|
+
await writeFile(join(root, input.path), input.content, 'utf8');
|
|
33
|
+
if (input.evidenceDisposition !== 'preserved') {
|
|
34
|
+
const map = await readEvidenceMap(root);
|
|
35
|
+
if (map?.pages[input.path]) {
|
|
36
|
+
map.pages[input.path] = { sources: map.pages[input.path].sources, confidence: 'needs-human' };
|
|
37
|
+
await writeEvidenceMap(root, map);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} });
|
|
41
|
+
return { ...await readPageContent(root, input.path), ...result };
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
export function previewPageContent(content) {
|
|
45
|
+
checkContent(content);
|
|
46
|
+
const body = matter(content).content;
|
|
47
|
+
return { html: `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; img-src data: https:; style-src 'unsafe-inline'"><style>body{font:16px/1.65 system-ui;max-width:760px;margin:32px auto;padding:0 24px;color:#17213a}pre{overflow:auto;padding:16px;background:#f2f4f8}img{max-width:100%}</style></head><body>${renderMarkdown(body).html}</body></html>` };
|
|
48
|
+
}
|
|
49
|
+
/** Pages, navigation, links and evidence are one reversible write. */
|
|
50
|
+
export async function changePageLifecycle(root, input) {
|
|
51
|
+
return withProjectLock(root, 'write', async () => {
|
|
52
|
+
const project = await loadProject(root);
|
|
53
|
+
const adapter = project.generator === 'doxbrix' ? undefined : await loadGeneratorAdapter(root, project);
|
|
54
|
+
if ((adapter?.project.contentFormat ?? 'markdown') !== 'markdown')
|
|
55
|
+
throw new DoxloopError('Page lifecycle operations currently support Markdown and MDX. Edit this page in its native format instead.');
|
|
56
|
+
const known = (await loadPages(root, project)).map((path) => relativePath(root, path));
|
|
57
|
+
const destination = input.action === 'rename' ? input.to : input.path;
|
|
58
|
+
if (input.action !== 'delete')
|
|
59
|
+
await validateNewPath(root, project, destination, known, adapter?.project.pageExtensions ?? ['.md', '.mdx']);
|
|
60
|
+
if (project.generator === 'doxbrix' && destination && input.action !== 'delete') {
|
|
61
|
+
const route = await resolvePageRoute(root, project, destination, input.content ?? '');
|
|
62
|
+
if ((await readRedirects(root))[route])
|
|
63
|
+
throw new DoxloopError('This address redirects an older page. Choose a new address or undo the previous move first.');
|
|
64
|
+
}
|
|
65
|
+
const old = input.action === 'create' ? undefined : await readPageContent(root, input.path);
|
|
66
|
+
if (old && old.fingerprint !== input.fingerprint)
|
|
67
|
+
throw new DoxloopError('The page changed since it was loaded. Reload before renaming or deleting it.');
|
|
68
|
+
if (input.action === 'delete' && known.length <= 1)
|
|
69
|
+
throw new DoxloopError('Keep at least one documentation page.');
|
|
70
|
+
if (input.replacement && (!known.includes(input.replacement) || input.replacement === input.path))
|
|
71
|
+
throw new DoxloopError('Choose a different existing page as the replacement.');
|
|
72
|
+
const navFiles = project.generator === 'doxbrix' ? [relativePath(root, await siteConfigPath(root, project))] : adapter?.planning?.navigationFiles ?? [];
|
|
73
|
+
if (adapter && !adapter.writeNavigation && project.generator === 'docusaurus') {
|
|
74
|
+
const sidebar = await readFile(join(root, 'sidebars.js'), 'utf8').catch(() => '');
|
|
75
|
+
if (!/type\s*:\s*['"]autogenerated['"]/.test(sidebar) || !/dirName\s*:\s*['"].['"]/.test(sidebar))
|
|
76
|
+
throw new DoxloopError('This Docusaurus sidebar is custom code. Use the agent to update the page and sidebar together; direct lifecycle changes require autogenerated navigation.');
|
|
77
|
+
}
|
|
78
|
+
else if (adapter && !adapter.writeNavigation) {
|
|
79
|
+
throw new DoxloopError('This generator does not expose safe navigation changes. Use an agent proposal for page lifecycle changes.');
|
|
80
|
+
}
|
|
81
|
+
const target = input.action === 'rename' ? input.to : input.replacement;
|
|
82
|
+
const oldRoute = old ? await resolvePageRoute(root, project, input.path, old.content) : undefined;
|
|
83
|
+
let content = input.content ?? (old ? old.content : `---\ntitle: ${JSON.stringify(input.title?.trim() || 'New page')}\ndescription: "Describe what readers will learn on this page."\n---\n\n# ${input.title?.trim() || 'New page'}\n\nWrite the reader outcome, instructions, and supporting evidence here.\n`);
|
|
84
|
+
checkContent(content);
|
|
85
|
+
if (input.action === 'rename' && project.generator === 'docusaurus') {
|
|
86
|
+
// File moves preserve the public URL, including custom slugs, without introducing redirects.
|
|
87
|
+
const parsed = matter(content);
|
|
88
|
+
if (typeof parsed.data.slug !== 'string') {
|
|
89
|
+
const rootRoute = await resolvePageRoute(root, project, `${project.contentDir}/index.md`, '---\nslug: /\n---\n');
|
|
90
|
+
parsed.data.slug = `/${oldRoute.slice(rootRoute === '/' ? 0 : rootRoute.length).replace(/^\//, '')}`;
|
|
91
|
+
}
|
|
92
|
+
content = matter.stringify(parsed.content, parsed.data);
|
|
93
|
+
}
|
|
94
|
+
const newRoute = target ? await resolvePageRoute(root, project, target, input.action === 'rename' ? content : undefined) : undefined;
|
|
95
|
+
const rewrites = new Map();
|
|
96
|
+
const inbound = [];
|
|
97
|
+
for (const page of known) {
|
|
98
|
+
const raw = page === input.path && old ? content : await readFile(join(root, page), 'utf8');
|
|
99
|
+
const moved = input.action === 'rename' && page === input.path;
|
|
100
|
+
const rewritten = rewriteLinks(raw, (href) => {
|
|
101
|
+
if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/\/|#)/i.test(href))
|
|
102
|
+
return href;
|
|
103
|
+
const [url, suffix = ''] = splitSuffix(href);
|
|
104
|
+
const resolved = url.startsWith('/') ? resolve(root, project.contentDir, `.${url}`) : resolve(root, dirname(page), url);
|
|
105
|
+
const matches = old && (samePage(resolved, resolve(root, input.path)) || url === oldRoute);
|
|
106
|
+
if (matches && page !== input.path)
|
|
107
|
+
inbound.push(page);
|
|
108
|
+
if (matches && target) {
|
|
109
|
+
if (url.startsWith('/'))
|
|
110
|
+
return `${newRoute}${suffix}`;
|
|
111
|
+
return `${portable(relative(dirname(join(root, moved ? input.to : page)), join(root, target)))}${suffix}`;
|
|
112
|
+
}
|
|
113
|
+
if (moved && !url.startsWith('/'))
|
|
114
|
+
return `${portable(relative(dirname(join(root, input.to)), resolved))}${suffix}`;
|
|
115
|
+
return href;
|
|
116
|
+
});
|
|
117
|
+
if (rewritten !== raw || moved)
|
|
118
|
+
rewrites.set(moved ? input.to : page, rewritten);
|
|
119
|
+
}
|
|
120
|
+
if (input.action === 'delete' && inbound.length && !target)
|
|
121
|
+
throw new DoxloopError(`This page is linked from ${[...new Set(inbound)].join(', ')}. Choose a replacement page before deleting it.`);
|
|
122
|
+
const files = [...new Set([input.path, ...(destination ? [destination] : []), ...rewrites.keys(), ...navFiles, EVIDENCE_MAP_FILE, REDIRECTS_FILE])];
|
|
123
|
+
const result = await applyDirectEdit(root, { kind: 'edit', requestText: `${input.action === 'create' ? 'Created' : input.action === 'rename' ? 'Moved' : 'Deleted'} ${input.path}${target ? ` → ${target}` : ''}`, files, pagesChanged: Math.max(1, rewrites.size), apply: async () => {
|
|
124
|
+
if (old && fingerprint(await readFile(join(root, input.path), 'utf8')) !== old.fingerprint)
|
|
125
|
+
throw new DoxloopError('The page changed before the operation started.');
|
|
126
|
+
for (const [path, text] of rewrites) {
|
|
127
|
+
await mkdir(dirname(join(root, path)), { recursive: true });
|
|
128
|
+
await writeFile(join(root, path), text, 'utf8');
|
|
129
|
+
}
|
|
130
|
+
if (input.action === 'create') {
|
|
131
|
+
await mkdir(dirname(join(root, input.path)), { recursive: true });
|
|
132
|
+
await writeFile(join(root, input.path), content, { flag: 'wx' });
|
|
133
|
+
}
|
|
134
|
+
else
|
|
135
|
+
await rm(join(root, input.path));
|
|
136
|
+
const title = matter(content).data.title || input.title || input.path;
|
|
137
|
+
if (project.generator === 'doxbrix')
|
|
138
|
+
await updateDoxbrixNavigation(root, project, input, String(title));
|
|
139
|
+
else
|
|
140
|
+
await adapter?.writeNavigation?.({ root, contentRoot: join(root, project.contentDir), project, action: input.action === 'create' ? 'add' : input.action === 'delete' ? 'remove' : 'rename', page: { path: relative(join(root, project.contentDir), join(root, input.action === 'rename' ? input.to : input.path)), title: String(title) }, ...(input.action === 'rename' ? { from: relative(join(root, project.contentDir), join(root, input.path)) } : {}) });
|
|
141
|
+
const map = await readEvidenceMap(root) ?? { schemaVersion: 1, pages: {} };
|
|
142
|
+
if (input.action === 'rename')
|
|
143
|
+
map.pages[input.to] = map.pages[input.path] ?? { sources: [], confidence: 'needs-human' };
|
|
144
|
+
if (input.action === 'create')
|
|
145
|
+
map.pages[input.path] = { sources: [], confidence: 'needs-human' };
|
|
146
|
+
else
|
|
147
|
+
delete map.pages[input.path];
|
|
148
|
+
await writeEvidenceMap(root, map);
|
|
149
|
+
if (oldRoute && newRoute && oldRoute !== newRoute) {
|
|
150
|
+
if (project.generator === 'doxbrix') {
|
|
151
|
+
const redirects = await readRedirects(root);
|
|
152
|
+
for (const [from, to] of Object.entries(redirects))
|
|
153
|
+
if (to === oldRoute)
|
|
154
|
+
redirects[from] = newRoute;
|
|
155
|
+
redirects[oldRoute] = newRoute;
|
|
156
|
+
await writeFile(join(root, REDIRECTS_FILE), `${JSON.stringify(redirects, null, 2)}\n`);
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// Native generators render this compatibility page at the old address.
|
|
160
|
+
const slug = project.generator === 'docusaurus' && typeof matter(old.content).data.slug === 'string' ? `slug: ${JSON.stringify(matter(old.content).data.slug)}\n` : '';
|
|
161
|
+
const stub = `---\n${slug}title: ${JSON.stringify(`${title} (moved)`)}\ndescription: "This documentation page has moved."\n---\n\n<meta http-equiv="refresh" content="0; url=${escapeHtml(newRoute)}" />\n\n# This page has moved\n\n[Continue to the current page](${newRoute}). Update your bookmark to use the new address.\n`;
|
|
162
|
+
await writeFile(join(root, input.path), stub, 'utf8');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
} });
|
|
166
|
+
return { ...result, ...(input.action === 'delete' ? {} : { path: destination }) };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
async function validateNewPath(root, project, path, known, extensions) {
|
|
170
|
+
if (!path || /[\s?#%<>"'`]/.test(path) || path.split(/[\\/]/).some((part) => !part || part.startsWith('.')) || !extensions.includes(extname(path)))
|
|
171
|
+
throw new DoxloopError('Use a relative Markdown path with letters, numbers, hyphens, and an allowed extension.');
|
|
172
|
+
const absolute = await safePath(root, path);
|
|
173
|
+
const content = resolve(root, project.contentDir);
|
|
174
|
+
if (!absolute.startsWith(`${content}/`))
|
|
175
|
+
throw new DoxloopError('The new page must be inside the documentation content directory.');
|
|
176
|
+
if (known.some((file) => file.toLowerCase() === path.toLowerCase()) || await pathExists(absolute))
|
|
177
|
+
throw new DoxloopError('A page or file already exists at that path.');
|
|
178
|
+
}
|
|
179
|
+
async function updateDoxbrixNavigation(root, project, input, title) {
|
|
180
|
+
const contentRoot = join(root, project.contentDir);
|
|
181
|
+
if (input.action === 'create')
|
|
182
|
+
return appendDoxbrixNavigationPage(root, project, { file: pageId(contentRoot, join(root, input.path)), title });
|
|
183
|
+
const site = await loadSiteConfig(root, project);
|
|
184
|
+
const oldId = pageId(contentRoot, join(root, input.path));
|
|
185
|
+
const walk = (nodes) => nodes.flatMap((node) => node.type === 'page' && node.file === oldId ? input.action === 'rename' ? [{ ...node, file: pageId(contentRoot, join(root, input.to)) }] : [] : node.type === 'group' ? [{ ...node, items: walk(node.items) }] : [node]);
|
|
186
|
+
site.spaces = site.spaces.map((space) => ({ ...space, nav: walk(space.nav) }));
|
|
187
|
+
await writeFile(await siteConfigPath(root, project), `${JSON.stringify(site, null, 2)}\n`);
|
|
188
|
+
}
|
|
189
|
+
export async function readRedirects(root) {
|
|
190
|
+
try {
|
|
191
|
+
const raw = JSON.parse(await readFile(join(root, REDIRECTS_FILE), 'utf8'));
|
|
192
|
+
// The root always serves the navigation homepage; a stored root redirect only hides it.
|
|
193
|
+
return Object.fromEntries(Object.entries(raw).filter(([from, to]) => safeRoute(from) && from !== '/' && typeof to === 'string' && safeRoute(to) && to !== from));
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
if (error.code === 'ENOENT')
|
|
197
|
+
return {};
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function safeRoute(route) { return /^\/(?!\/)[a-zA-Z0-9/_-]*$/.test(route) && !route.split('/').includes('..'); }
|
|
202
|
+
function checkContent(content) { if (typeof content !== 'string' || Buffer.byteLength(content) > 1_000_000 || content.includes('\0'))
|
|
203
|
+
throw new DoxloopError('Page text must be valid text under 1 MB.'); }
|
|
204
|
+
function samePage(a, b) { return a === b || a.replace(/\.(md|mdx)$/, '') === b.replace(/\.(md|mdx)$/, ''); }
|
|
205
|
+
function portable(path) { return path.replace(/\\/g, '/'); }
|
|
206
|
+
function splitSuffix(href) { const index = href.search(/[?#]/); return index < 0 ? [href] : [href.slice(0, index), href.slice(index)]; }
|
|
207
|
+
export function rewriteLinks(raw, rewrite) {
|
|
208
|
+
// Fences and inline code are examples, not navigation. Reference definitions are links too.
|
|
209
|
+
return raw.split(/(^[ \t]*```[^\n]*\n[\s\S]*?^[ \t]*```[^\n]*$|^[ \t]*~~~[^\n]*\n[\s\S]*?^[ \t]*~~~[^\n]*$|`+[^`\n]*`+)/gm).map((part, index) => index % 2 ? part : part
|
|
210
|
+
.replace(/(!?\[[^\]]*\]\()([^\s)]+)([^)]*\))/g, (_, start, href, end) => `${start}${rewrite(href)}${end}`)
|
|
211
|
+
.replace(/(^[ \t]{0,3}\[[^\]]+\]:\s*<?)([^\s>]+)(>?)/gm, (_, start, href, end) => `${start}${rewrite(href)}${end}`)
|
|
212
|
+
.replace(/(\b(?:src|href)=["'])([^"']+)(["'])/g, (_, start, href, end) => `${start}${rewrite(href)}${end}`)).join('');
|
|
213
|
+
}
|
|
214
|
+
function escapeHtml(value) { return value.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<'); }
|
|
215
|
+
//# sourceMappingURL=page-operations.js.map
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { DoxloopProject } from './types.js';
|
|
2
|
+
/** Read native routing without executing project configuration code. */
|
|
3
|
+
export declare function resolvePageRoute(root: string, project: DoxloopProject, path: string, content?: string): Promise<string>;
|
|
4
|
+
//# sourceMappingURL=page-routes.d.ts.map
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { documentationCollections, collectionForPath } from './documentation-collections.js';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import matter from 'gray-matter';
|
|
5
|
+
import { parseDocument } from 'yaml';
|
|
6
|
+
/** Read native routing without executing project configuration code. */
|
|
7
|
+
export async function resolvePageRoute(root, project, path, content) {
|
|
8
|
+
const collection = collectionForPath(await documentationCollections(root, project), path);
|
|
9
|
+
const directory = project.generator === 'docusaurus' ? collection?.directory ?? project.contentDir : project.contentDir;
|
|
10
|
+
const relative = path.replace(/\\/g, '/').slice(directory ? directory.length + 1 : 0);
|
|
11
|
+
let route = relative.replace(/\.[^/.]+$/, '').replace(/(^|\/)index$/, '');
|
|
12
|
+
if (project.generator === 'docusaurus') {
|
|
13
|
+
const raw = content ?? await readFile(join(root, path), 'utf8');
|
|
14
|
+
const slug = matter(raw).data.slug;
|
|
15
|
+
const config = await readFirst(root, ['docusaurus.config.js', 'docusaurus.config.ts', 'docusaurus.config.mjs', 'docusaurus.config.cjs']);
|
|
16
|
+
const base = literal(config, 'baseUrl') ?? '/';
|
|
17
|
+
const docs = literal(config, 'routeBasePath') ?? '/docs';
|
|
18
|
+
if (typeof slug === 'string')
|
|
19
|
+
route = slug;
|
|
20
|
+
else
|
|
21
|
+
route = route.split('/').map((segment) => segment.replace(/^\d+[-_]/, '')).join('/');
|
|
22
|
+
let versions = [];
|
|
23
|
+
try {
|
|
24
|
+
versions = JSON.parse(await readFile(join(root, 'versions.json'), 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
catch { /* An unversioned site. */ }
|
|
27
|
+
const version = collection?.version ?? 'current';
|
|
28
|
+
const versionPath = versions.length ? version === 'current' ? 'next' : version === versions[0] ? '' : version : '';
|
|
29
|
+
const localePath = collection?.locale && collection.locale !== 'default' ? collection.locale : '';
|
|
30
|
+
return clean(`${base}/${localePath}/${docs}/${versionPath}/${route}`);
|
|
31
|
+
}
|
|
32
|
+
if (project.generator === 'mkdocs') {
|
|
33
|
+
const config = parseDocument(await readFirst(root, ['mkdocs.yml', 'mkdocs.yaml']), { logLevel: 'silent' }).toJS();
|
|
34
|
+
let base = '';
|
|
35
|
+
if (typeof config?.site_url === 'string') {
|
|
36
|
+
try {
|
|
37
|
+
base = new URL(config.site_url).pathname;
|
|
38
|
+
}
|
|
39
|
+
catch { /* Native build reports invalid URLs. */ }
|
|
40
|
+
}
|
|
41
|
+
if (config?.use_directory_urls === false && route)
|
|
42
|
+
route += '.html';
|
|
43
|
+
return clean(`${base}/${route}`);
|
|
44
|
+
}
|
|
45
|
+
return clean(`/${route}`);
|
|
46
|
+
}
|
|
47
|
+
function clean(value) { return `/${value}`.replace(/\/+/g, '/').replace(/\/$/, '') || '/'; }
|
|
48
|
+
function literal(config, key) { return new RegExp(`\\b${key}\\s*:\\s*['\"]([^'\"]*)['\"]`).exec(config)?.[1]; }
|
|
49
|
+
async function readFirst(root, paths) {
|
|
50
|
+
for (const path of paths) {
|
|
51
|
+
try {
|
|
52
|
+
return await readFile(join(root, path), 'utf8');
|
|
53
|
+
}
|
|
54
|
+
catch (error) {
|
|
55
|
+
if (error.code !== 'ENOENT')
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return '';
|
|
60
|
+
}
|
|
61
|
+
//# sourceMappingURL=page-routes.js.map
|
package/dist/pages.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { RevisionScope } from './sync-runs.js';
|
|
2
|
+
import type { DoxloopProject } from './types.js';
|
|
3
|
+
export interface PageSummary {
|
|
4
|
+
path: string;
|
|
5
|
+
title: string;
|
|
6
|
+
description?: string;
|
|
7
|
+
section?: string;
|
|
8
|
+
route: string;
|
|
9
|
+
wordCount: number;
|
|
10
|
+
updatedAt?: string;
|
|
11
|
+
evidence: 'verified' | 'needs-review' | 'none';
|
|
12
|
+
inNavigation: boolean;
|
|
13
|
+
version?: string;
|
|
14
|
+
locale?: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function listPages(root: string): Promise<PageSummary[]>;
|
|
17
|
+
/** Map a project-relative page file to the conventional local-preview route. */
|
|
18
|
+
export declare function pageRoute(project: DoxloopProject, path: string): string;
|
|
19
|
+
export declare function resolveEditScope(root: string, project: DoxloopProject, paths: readonly string[], allowRelated: boolean): Promise<RevisionScope>;
|
|
20
|
+
//# sourceMappingURL=pages.d.ts.map
|