@doxbrix/doxloop 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1145 -0
- package/README.md +422 -227
- 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 +24 -5
- package/dist/agents.js +60 -9
- 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 +3 -0
- 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 +25 -0
- 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 +49 -32
- 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,86 @@
|
|
|
1
|
+
export declare const DEFAULT_DOCS_CRAWL_PAGE_LIMIT = 150;
|
|
2
|
+
export declare const MAX_DOCS_CRAWL_PAGE_LIMIT = 500;
|
|
3
|
+
export type DocsCrawlDiscovery = 'sitemap' | 'llms-txt' | 'links';
|
|
4
|
+
export interface DocsSitePage {
|
|
5
|
+
/** Normalized absolute URL. */
|
|
6
|
+
url: string;
|
|
7
|
+
/** Path relative to the crawl scope, without a leading slash. Empty for the entry page. */
|
|
8
|
+
path: string;
|
|
9
|
+
title: string;
|
|
10
|
+
description?: string;
|
|
11
|
+
language?: string;
|
|
12
|
+
headings: Array<{
|
|
13
|
+
level: number;
|
|
14
|
+
text: string;
|
|
15
|
+
}>;
|
|
16
|
+
words: number;
|
|
17
|
+
/** Same-scope documentation links (normalized absolute URLs). */
|
|
18
|
+
internalLinks: string[];
|
|
19
|
+
/** Links outside the crawl scope. */
|
|
20
|
+
externalLinks: string[];
|
|
21
|
+
images: string[];
|
|
22
|
+
markdown: string;
|
|
23
|
+
hash: string;
|
|
24
|
+
fetchedAt: string;
|
|
25
|
+
}
|
|
26
|
+
export interface DocsSiteSkippedPage {
|
|
27
|
+
url: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
status?: number;
|
|
30
|
+
}
|
|
31
|
+
export interface DocsSiteSnapshot {
|
|
32
|
+
schemaVersion: 1;
|
|
33
|
+
/** Entry URL exactly as configured. */
|
|
34
|
+
url: string;
|
|
35
|
+
origin: string;
|
|
36
|
+
/** Scope prefix (path of the entry URL, normalized to end with `/`). */
|
|
37
|
+
scope: string;
|
|
38
|
+
crawledAt: string;
|
|
39
|
+
generator?: string;
|
|
40
|
+
discovery: DocsCrawlDiscovery[];
|
|
41
|
+
pageLimit: number;
|
|
42
|
+
/** True when discovery found more pages than the crawl limit allowed. */
|
|
43
|
+
truncated: boolean;
|
|
44
|
+
pages: DocsSitePage[];
|
|
45
|
+
skipped: DocsSiteSkippedPage[];
|
|
46
|
+
/** Same-scope links that pointed at pages which failed to load. */
|
|
47
|
+
brokenLinks: Array<{
|
|
48
|
+
url: string;
|
|
49
|
+
from: string;
|
|
50
|
+
status?: number;
|
|
51
|
+
}>;
|
|
52
|
+
warnings: string[];
|
|
53
|
+
totals: {
|
|
54
|
+
pages: number;
|
|
55
|
+
words: number;
|
|
56
|
+
images: number;
|
|
57
|
+
discovered: number;
|
|
58
|
+
};
|
|
59
|
+
hash: string;
|
|
60
|
+
}
|
|
61
|
+
export interface DocsCrawlOptions {
|
|
62
|
+
fetch?: typeof globalThis.fetch;
|
|
63
|
+
resolveHostname?: (hostname: string) => Promise<string[]>;
|
|
64
|
+
pageLimit?: number;
|
|
65
|
+
timeoutMs?: number;
|
|
66
|
+
concurrency?: number;
|
|
67
|
+
/** Cookie header from a recorded sign-in session for protected documentation. */
|
|
68
|
+
cookieHeader?: string;
|
|
69
|
+
onProgress?: (progress: {
|
|
70
|
+
fetched: number;
|
|
71
|
+
discovered: number;
|
|
72
|
+
url: string;
|
|
73
|
+
}) => void;
|
|
74
|
+
signal?: AbortSignal;
|
|
75
|
+
}
|
|
76
|
+
export interface DocsCrawlScope {
|
|
77
|
+
origin: string;
|
|
78
|
+
scope: string;
|
|
79
|
+
entry: string;
|
|
80
|
+
}
|
|
81
|
+
/** Validate and normalize a documentation site URL into a crawl scope. */
|
|
82
|
+
export declare function docsSiteScope(raw: string): DocsCrawlScope;
|
|
83
|
+
export declare function isDocsSiteUrl(location: string): boolean;
|
|
84
|
+
/** Crawl a documentation website into a Markdown snapshot. */
|
|
85
|
+
export declare function crawlDocumentationSite(rawUrl: string, options?: DocsCrawlOptions): Promise<DocsSiteSnapshot>;
|
|
86
|
+
//# sourceMappingURL=docs-crawl.d.ts.map
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded crawler for existing documentation websites.
|
|
3
|
+
*
|
|
4
|
+
* A docs-site source points at a live documentation URL. The crawler discovers
|
|
5
|
+
* pages through the sitemap, `llms.txt`, and same-scope link following, then
|
|
6
|
+
* converts each HTML page into Markdown so the authoring agent can read the
|
|
7
|
+
* existing documentation as evidence alongside product source code.
|
|
8
|
+
*
|
|
9
|
+
* Safety controls mirror the remote OpenAPI fetcher: HTTPS/HTTP only, no
|
|
10
|
+
* embedded credentials, public hosts only, redirect and size limits, and a hard
|
|
11
|
+
* page cap. The crawler never executes page scripts.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from 'node:crypto';
|
|
14
|
+
import { lookup } from 'node:dns/promises';
|
|
15
|
+
import { isIP } from 'node:net';
|
|
16
|
+
import { isPrivateAddress } from './capture.js';
|
|
17
|
+
import { DoxloopError } from './errors.js';
|
|
18
|
+
import { summarizeHtmlDocument, summarizeMarkdownDocument, decodeEntities } from './html-markdown.js';
|
|
19
|
+
export const DEFAULT_DOCS_CRAWL_PAGE_LIMIT = 150;
|
|
20
|
+
export const MAX_DOCS_CRAWL_PAGE_LIMIT = 500;
|
|
21
|
+
const MAX_PAGE_BYTES = 2 * 1024 * 1024;
|
|
22
|
+
const MAX_SITEMAP_BYTES = 5 * 1024 * 1024;
|
|
23
|
+
const MAX_REDIRECTS = 3;
|
|
24
|
+
const MAX_SITEMAP_FILES = 8;
|
|
25
|
+
const DEFAULT_TIMEOUT_MS = 20_000;
|
|
26
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
27
|
+
const USER_AGENT = 'doxloop-docs-crawler/1 (+https://github.com/doxbrix/doxloop)';
|
|
28
|
+
const NON_PAGE_EXTENSIONS = /\.(png|jpe?g|gif|svg|webp|avif|ico|pdf|zip|gz|tgz|tar|mp4|mp3|webm|woff2?|ttf|otf|css|js|mjs|json|xml|txt|yaml|yml|csv|rss|atom)$/i;
|
|
29
|
+
/** Validate and normalize a documentation site URL into a crawl scope. */
|
|
30
|
+
export function docsSiteScope(raw) {
|
|
31
|
+
let url;
|
|
32
|
+
try {
|
|
33
|
+
url = new URL(raw.trim());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
throw new DoxloopError(`Invalid documentation site URL: ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
39
|
+
throw new DoxloopError('Documentation site URLs must use HTTP or HTTPS.');
|
|
40
|
+
if (url.username || url.password)
|
|
41
|
+
throw new DoxloopError('Documentation site URLs cannot contain embedded credentials.');
|
|
42
|
+
url.hash = '';
|
|
43
|
+
url.search = '';
|
|
44
|
+
const entry = url.toString();
|
|
45
|
+
let scope = url.pathname;
|
|
46
|
+
if (NON_PAGE_EXTENSIONS.test(scope) || /\.html?$/i.test(scope))
|
|
47
|
+
scope = scope.slice(0, scope.lastIndexOf('/') + 1);
|
|
48
|
+
if (!scope.endsWith('/'))
|
|
49
|
+
scope = `${scope}/`;
|
|
50
|
+
return { origin: url.origin, scope, entry };
|
|
51
|
+
}
|
|
52
|
+
export function isDocsSiteUrl(location) {
|
|
53
|
+
return /^https?:\/\//i.test(location);
|
|
54
|
+
}
|
|
55
|
+
/** Crawl a documentation website into a Markdown snapshot. */
|
|
56
|
+
export async function crawlDocumentationSite(rawUrl, options = {}) {
|
|
57
|
+
const { origin, scope, entry } = docsSiteScope(rawUrl);
|
|
58
|
+
const pageLimit = Math.min(MAX_DOCS_CRAWL_PAGE_LIMIT, Math.max(1, options.pageLimit ?? DEFAULT_DOCS_CRAWL_PAGE_LIMIT));
|
|
59
|
+
const context = {
|
|
60
|
+
fetcher: options.fetch ?? globalThis.fetch,
|
|
61
|
+
resolveHostname: options.resolveHostname ?? defaultResolveHostname,
|
|
62
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
63
|
+
...(options.cookieHeader ? { cookieHeader: options.cookieHeader } : {}),
|
|
64
|
+
robots: { disallow: [], allow: [], sitemaps: [] },
|
|
65
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
66
|
+
};
|
|
67
|
+
await assertPublicHost(new URL(entry), context.resolveHostname);
|
|
68
|
+
context.robots = await loadRobots(origin, context);
|
|
69
|
+
const warnings = [];
|
|
70
|
+
const discovery = [];
|
|
71
|
+
const scopeRoot = scope.length > 1 ? scope.slice(0, -1) : scope;
|
|
72
|
+
const inScope = (url) => {
|
|
73
|
+
if (!url.startsWith(origin))
|
|
74
|
+
return false;
|
|
75
|
+
const pathname = new URL(url).pathname;
|
|
76
|
+
return pathname === scopeRoot || pathname.startsWith(scope);
|
|
77
|
+
};
|
|
78
|
+
const normalize = (raw, base) => normalizeUrl(raw, base);
|
|
79
|
+
const queue = [];
|
|
80
|
+
const seen = new Set();
|
|
81
|
+
const enqueue = (url) => {
|
|
82
|
+
if (!url || seen.has(url) || !inScope(url))
|
|
83
|
+
return;
|
|
84
|
+
if (NON_PAGE_EXTENSIONS.test(new URL(url).pathname))
|
|
85
|
+
return;
|
|
86
|
+
if (!robotsAllows(context.robots, new URL(url).pathname))
|
|
87
|
+
return;
|
|
88
|
+
seen.add(url);
|
|
89
|
+
queue.push(url);
|
|
90
|
+
};
|
|
91
|
+
enqueue(normalize(entry, entry));
|
|
92
|
+
const sitemapUrls = await discoverSitemap(origin, scope, context, warnings);
|
|
93
|
+
if (sitemapUrls.length > 0) {
|
|
94
|
+
discovery.push('sitemap');
|
|
95
|
+
for (const url of sitemapUrls)
|
|
96
|
+
enqueue(normalize(url, origin));
|
|
97
|
+
}
|
|
98
|
+
const llmsUrls = await discoverLlmsText(origin, scope, context);
|
|
99
|
+
if (llmsUrls.length > 0) {
|
|
100
|
+
discovery.push('llms-txt');
|
|
101
|
+
for (const url of llmsUrls)
|
|
102
|
+
enqueue(normalize(url, origin));
|
|
103
|
+
}
|
|
104
|
+
const pages = [];
|
|
105
|
+
const skipped = [];
|
|
106
|
+
const linkSources = new Map();
|
|
107
|
+
let generator;
|
|
108
|
+
let fetched = 0;
|
|
109
|
+
let followedLinks = false;
|
|
110
|
+
let inFlight = 0;
|
|
111
|
+
// A worker with nothing queued waits while another worker's page is still
|
|
112
|
+
// loading, because that page may link to more pages; it stops only when the
|
|
113
|
+
// queue is empty and nothing is in flight.
|
|
114
|
+
const worker = async () => {
|
|
115
|
+
while (pages.length < pageLimit) {
|
|
116
|
+
if (queue.length === 0) {
|
|
117
|
+
if (inFlight === 0)
|
|
118
|
+
break;
|
|
119
|
+
await new Promise((resolveWait) => setTimeout(resolveWait, 25));
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const url = queue.shift();
|
|
123
|
+
inFlight += 1;
|
|
124
|
+
try {
|
|
125
|
+
await crawlOne(url);
|
|
126
|
+
}
|
|
127
|
+
finally {
|
|
128
|
+
inFlight -= 1;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const crawlOne = async (url) => {
|
|
133
|
+
fetched += 1;
|
|
134
|
+
options.onProgress?.({ fetched, discovered: seen.size, url });
|
|
135
|
+
let result;
|
|
136
|
+
try {
|
|
137
|
+
result = await fetchPage(url, context);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
skipped.push({ url, reason: error instanceof Error ? error.message : String(error) });
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (result.status >= 400) {
|
|
144
|
+
skipped.push({ url, reason: `HTTP ${result.status}`, status: result.status });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
// A page's Markdown source (`/guide.md`, listed by llms.txt) stands for
|
|
148
|
+
// the page itself; it is keyed by the page URL so the HTML and Markdown
|
|
149
|
+
// copies of one page are never both kept.
|
|
150
|
+
const markdownSource = isMarkdownResponse(result.url, result.contentType);
|
|
151
|
+
if (!markdownSource && !result.contentType.includes('text/html') && !result.contentType.includes('application/xhtml')) {
|
|
152
|
+
skipped.push({ url, reason: `Unsupported content type ${result.contentType || 'unknown'}` });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const fetchedUrl = normalize(result.url, url) ?? url;
|
|
156
|
+
const finalUrl = markdownSource ? pageUrlOfMarkdown(fetchedUrl) : fetchedUrl;
|
|
157
|
+
if (finalUrl !== url) {
|
|
158
|
+
if (seen.has(finalUrl) && pages.some((page) => page.url === finalUrl))
|
|
159
|
+
return;
|
|
160
|
+
seen.add(finalUrl);
|
|
161
|
+
if (!inScope(finalUrl)) {
|
|
162
|
+
skipped.push({ url, reason: `Redirected outside the documentation scope to ${finalUrl}` });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (pages.some((page) => page.url === finalUrl))
|
|
167
|
+
return;
|
|
168
|
+
let summary = markdownSource ? summarizeMarkdownDocument(result.body) : summarizeHtmlDocument(result.body);
|
|
169
|
+
// A client-rendered page (Mintlify, GitBook, Docusaurus SPA builds)
|
|
170
|
+
// arrives as an empty shell; its Markdown source carries the content.
|
|
171
|
+
if (!markdownSource && summary.words < MIN_RENDERED_WORDS) {
|
|
172
|
+
const source = await fetchMarkdownSource(finalUrl, context);
|
|
173
|
+
if (source) {
|
|
174
|
+
const fromSource = summarizeMarkdownDocument(source);
|
|
175
|
+
summary = { ...fromSource, ...(summary.generator ? { generator: summary.generator } : {}), ...(summary.title && !fromSource.title ? { title: summary.title } : {}), links: [...summary.links, ...fromSource.links] };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
if (pages.some((page) => page.url === finalUrl))
|
|
179
|
+
return;
|
|
180
|
+
if (!generator && summary.generator)
|
|
181
|
+
generator = summary.generator;
|
|
182
|
+
const internal = [];
|
|
183
|
+
const external = [];
|
|
184
|
+
for (const link of summary.links) {
|
|
185
|
+
const resolved = normalize(link, finalUrl);
|
|
186
|
+
if (!resolved)
|
|
187
|
+
continue;
|
|
188
|
+
if (inScope(resolved)) {
|
|
189
|
+
if (!internal.includes(resolved))
|
|
190
|
+
internal.push(resolved);
|
|
191
|
+
if (!seen.has(resolved)) {
|
|
192
|
+
followedLinks = true;
|
|
193
|
+
linkSources.set(resolved, finalUrl);
|
|
194
|
+
}
|
|
195
|
+
enqueue(resolved);
|
|
196
|
+
}
|
|
197
|
+
else if (!external.includes(resolved))
|
|
198
|
+
external.push(resolved);
|
|
199
|
+
}
|
|
200
|
+
const images = summary.images.map((image) => normalize(image, finalUrl) ?? image);
|
|
201
|
+
const rawTitle = summary.title?.replace(/\s*[|·–-]\s*[^|·–-]+$/, '').trim() || summary.headings.find((heading) => heading.level === 1)?.text || summary.headings[0]?.text || relativePath(finalUrl, origin, scope) || 'Home';
|
|
202
|
+
// Titles taken from Markdown sources keep inline-code backticks; readers never see those.
|
|
203
|
+
const title = rawTitle.replace(/`([^`]+)`/g, '$1');
|
|
204
|
+
pages.push({
|
|
205
|
+
url: finalUrl,
|
|
206
|
+
path: relativePath(finalUrl, origin, scope),
|
|
207
|
+
title,
|
|
208
|
+
...(summary.description ? { description: summary.description } : {}),
|
|
209
|
+
...(summary.language ? { language: summary.language } : {}),
|
|
210
|
+
headings: summary.headings,
|
|
211
|
+
words: summary.words,
|
|
212
|
+
internalLinks: internal,
|
|
213
|
+
externalLinks: external,
|
|
214
|
+
images,
|
|
215
|
+
markdown: summary.markdown,
|
|
216
|
+
hash: createHash('sha256').update(summary.markdown).digest('hex'),
|
|
217
|
+
fetchedAt: new Date().toISOString(),
|
|
218
|
+
});
|
|
219
|
+
};
|
|
220
|
+
const concurrency = Math.max(1, Math.min(8, options.concurrency ?? DEFAULT_CONCURRENCY));
|
|
221
|
+
await Promise.all(Array.from({ length: concurrency }, () => worker()));
|
|
222
|
+
if (followedLinks)
|
|
223
|
+
discovery.push('links');
|
|
224
|
+
const truncated = queue.length > 0 || pages.length >= pageLimit && seen.size > pages.length + skipped.length;
|
|
225
|
+
if (truncated)
|
|
226
|
+
warnings.push(`Crawl stopped at the ${pageLimit}-page limit; ${seen.size - pages.length - skipped.length} discovered pages were not fetched.`);
|
|
227
|
+
if (pages.length === 0)
|
|
228
|
+
throw new DoxloopError(`No documentation pages could be read from ${entry}. ${skipped[0]?.reason ?? 'The site returned no HTML pages.'}`);
|
|
229
|
+
const brokenLinks = skipped
|
|
230
|
+
.filter((item) => item.status !== undefined && item.status >= 400 && linkSources.has(item.url))
|
|
231
|
+
.map((item) => ({ url: item.url, from: linkSources.get(item.url), ...(item.status !== undefined ? { status: item.status } : {}) }));
|
|
232
|
+
pages.sort((left, right) => left.path.localeCompare(right.path));
|
|
233
|
+
const hash = createHash('sha256').update(pages.map((page) => `${page.url}\n${page.hash}`).join('\n')).digest('hex');
|
|
234
|
+
return {
|
|
235
|
+
schemaVersion: 1,
|
|
236
|
+
url: rawUrl.trim(),
|
|
237
|
+
origin,
|
|
238
|
+
scope,
|
|
239
|
+
crawledAt: new Date().toISOString(),
|
|
240
|
+
...(generator ? { generator: normalizeGenerator(generator) } : {}),
|
|
241
|
+
discovery,
|
|
242
|
+
pageLimit,
|
|
243
|
+
truncated,
|
|
244
|
+
pages,
|
|
245
|
+
skipped,
|
|
246
|
+
brokenLinks,
|
|
247
|
+
warnings,
|
|
248
|
+
totals: {
|
|
249
|
+
pages: pages.length,
|
|
250
|
+
words: pages.reduce((sum, page) => sum + page.words, 0),
|
|
251
|
+
images: pages.reduce((sum, page) => sum + page.images.length, 0),
|
|
252
|
+
discovered: seen.size,
|
|
253
|
+
},
|
|
254
|
+
hash,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function relativePath(url, origin, scope) {
|
|
258
|
+
const pathname = new URL(url).pathname;
|
|
259
|
+
const scopeRoot = scope.length > 1 ? scope.slice(0, -1) : scope;
|
|
260
|
+
const relative = pathname === scopeRoot ? '' : pathname.startsWith(scope) ? pathname.slice(scope.length) : pathname.replace(/^\//, '');
|
|
261
|
+
try {
|
|
262
|
+
return decodeURIComponent(relative).replace(/\/$/, '');
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return relative.replace(/\/$/, '');
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
function normalizeUrl(raw, base) {
|
|
269
|
+
const trimmed = raw.trim();
|
|
270
|
+
if (!trimmed || trimmed.startsWith('#') || /^(mailto|tel|javascript|data):/i.test(trimmed))
|
|
271
|
+
return undefined;
|
|
272
|
+
let url;
|
|
273
|
+
try {
|
|
274
|
+
url = new URL(trimmed, base);
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
280
|
+
return undefined;
|
|
281
|
+
url.hash = '';
|
|
282
|
+
url.search = '';
|
|
283
|
+
url.username = '';
|
|
284
|
+
url.password = '';
|
|
285
|
+
let pathname = url.pathname.replace(/\/{2,}/g, '/');
|
|
286
|
+
if (/\/index\.html?$/i.test(pathname))
|
|
287
|
+
pathname = pathname.replace(/index\.html?$/i, '');
|
|
288
|
+
if (pathname.length > 1 && pathname.endsWith('/'))
|
|
289
|
+
pathname = pathname.slice(0, -1);
|
|
290
|
+
url.pathname = pathname;
|
|
291
|
+
return url.toString();
|
|
292
|
+
}
|
|
293
|
+
function normalizeGenerator(raw) {
|
|
294
|
+
const value = raw.toLowerCase();
|
|
295
|
+
if (value.includes('docusaurus'))
|
|
296
|
+
return 'docusaurus';
|
|
297
|
+
if (value.includes('mkdocs'))
|
|
298
|
+
return 'mkdocs';
|
|
299
|
+
if (value.includes('sphinx'))
|
|
300
|
+
return 'sphinx';
|
|
301
|
+
if (value.includes('hugo'))
|
|
302
|
+
return 'hugo';
|
|
303
|
+
if (value.includes('vitepress'))
|
|
304
|
+
return 'vitepress';
|
|
305
|
+
if (value.includes('gitbook'))
|
|
306
|
+
return 'gitbook';
|
|
307
|
+
if (value.includes('mintlify'))
|
|
308
|
+
return 'mintlify';
|
|
309
|
+
if (value.includes('starlight') || value.includes('astro'))
|
|
310
|
+
return 'starlight';
|
|
311
|
+
if (value.includes('nextra') || value.includes('next.js'))
|
|
312
|
+
return 'nextra';
|
|
313
|
+
if (value.includes('jekyll'))
|
|
314
|
+
return 'jekyll';
|
|
315
|
+
if (value.includes('readme'))
|
|
316
|
+
return 'readme';
|
|
317
|
+
return raw.trim();
|
|
318
|
+
}
|
|
319
|
+
async function discoverSitemap(origin, scope, context, warnings) {
|
|
320
|
+
const candidates = unique([...(context.robots.sitemaps ?? []), `${origin}${scope}sitemap.xml`, `${origin}/sitemap.xml`, `${origin}/sitemap_index.xml`]);
|
|
321
|
+
const urls = [];
|
|
322
|
+
let files = 0;
|
|
323
|
+
const visit = async (sitemapUrl) => {
|
|
324
|
+
if (files >= MAX_SITEMAP_FILES)
|
|
325
|
+
return;
|
|
326
|
+
files += 1;
|
|
327
|
+
let result;
|
|
328
|
+
try {
|
|
329
|
+
result = await fetchText(sitemapUrl, context, MAX_SITEMAP_BYTES, 'application/xml, text/xml, text/plain;q=0.5');
|
|
330
|
+
}
|
|
331
|
+
catch {
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
if (result.status >= 400 || !result.body.includes('<'))
|
|
335
|
+
return;
|
|
336
|
+
const locations = [...result.body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/gi)].map((match) => decodeEntities(match[1]));
|
|
337
|
+
if (/<sitemapindex/i.test(result.body)) {
|
|
338
|
+
for (const child of locations)
|
|
339
|
+
await visit(child);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
urls.push(...locations);
|
|
343
|
+
};
|
|
344
|
+
for (const candidate of candidates) {
|
|
345
|
+
await visit(candidate);
|
|
346
|
+
if (urls.length > 0)
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
if (urls.length > 0 && !urls.some((url) => url.startsWith(origin) && new URL(url, origin).pathname.startsWith(scope))) {
|
|
350
|
+
warnings.push('The sitemap did not list any pages inside the documentation scope; pages were discovered by following links instead.');
|
|
351
|
+
}
|
|
352
|
+
return urls;
|
|
353
|
+
}
|
|
354
|
+
async function discoverLlmsText(origin, scope, context) {
|
|
355
|
+
for (const candidate of unique([`${origin}${scope}llms.txt`, `${origin}/llms.txt`])) {
|
|
356
|
+
let result;
|
|
357
|
+
try {
|
|
358
|
+
result = await fetchText(candidate, context, MAX_SITEMAP_BYTES, 'text/plain, text/markdown;q=0.9');
|
|
359
|
+
}
|
|
360
|
+
catch {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
if (result.status >= 400 || result.contentType.includes('text/html'))
|
|
364
|
+
continue;
|
|
365
|
+
const links = [...result.body.matchAll(/\]\((https?:\/\/[^)\s]+|\/[^)\s]*)\)/g)].map((match) => match[1]);
|
|
366
|
+
if (links.length > 0)
|
|
367
|
+
return links.map((link) => new URL(link, origin).toString());
|
|
368
|
+
}
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
async function loadRobots(origin, context) {
|
|
372
|
+
const rules = { disallow: [], allow: [], sitemaps: [] };
|
|
373
|
+
let result;
|
|
374
|
+
try {
|
|
375
|
+
result = await fetchText(`${origin}/robots.txt`, context, 512 * 1024, 'text/plain');
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
return rules;
|
|
379
|
+
}
|
|
380
|
+
if (result.status >= 400 || result.contentType.includes('text/html'))
|
|
381
|
+
return rules;
|
|
382
|
+
let applies = false;
|
|
383
|
+
for (const rawLine of result.body.split(/\r?\n/)) {
|
|
384
|
+
const line = rawLine.replace(/#.*$/, '').trim();
|
|
385
|
+
if (!line)
|
|
386
|
+
continue;
|
|
387
|
+
const separator = line.indexOf(':');
|
|
388
|
+
if (separator === -1)
|
|
389
|
+
continue;
|
|
390
|
+
const field = line.slice(0, separator).trim().toLowerCase();
|
|
391
|
+
const value = line.slice(separator + 1).trim();
|
|
392
|
+
if (field === 'user-agent')
|
|
393
|
+
applies = value === '*' || value.toLowerCase().includes('doxloop');
|
|
394
|
+
else if (field === 'sitemap')
|
|
395
|
+
rules.sitemaps.push(value);
|
|
396
|
+
else if (applies && field === 'disallow' && value)
|
|
397
|
+
rules.disallow.push(value);
|
|
398
|
+
else if (applies && field === 'allow' && value)
|
|
399
|
+
rules.allow.push(value);
|
|
400
|
+
}
|
|
401
|
+
return rules;
|
|
402
|
+
}
|
|
403
|
+
function robotsAllows(rules, pathname) {
|
|
404
|
+
const matches = (pattern) => {
|
|
405
|
+
const anchored = pattern.endsWith('$');
|
|
406
|
+
const body = anchored ? pattern.slice(0, -1) : pattern;
|
|
407
|
+
const escaped = body.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
|
|
408
|
+
return new RegExp(`^${escaped}${anchored ? '$' : ''}`).test(pathname);
|
|
409
|
+
};
|
|
410
|
+
const disallowed = rules.disallow.filter(matches).sort((left, right) => right.length - left.length)[0];
|
|
411
|
+
if (!disallowed)
|
|
412
|
+
return true;
|
|
413
|
+
const allowed = rules.allow.filter(matches).sort((left, right) => right.length - left.length)[0];
|
|
414
|
+
return Boolean(allowed && allowed.length >= disallowed.length);
|
|
415
|
+
}
|
|
416
|
+
/** Below this many words an HTML page is treated as a client-rendered shell. */
|
|
417
|
+
const MIN_RENDERED_WORDS = 40;
|
|
418
|
+
function isMarkdownResponse(url, contentType) {
|
|
419
|
+
let path = '';
|
|
420
|
+
try {
|
|
421
|
+
path = new URL(url).pathname;
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
return false;
|
|
425
|
+
}
|
|
426
|
+
return /\.mdx?$/i.test(path) && /text\/(?:markdown|x-markdown|plain)/.test(contentType);
|
|
427
|
+
}
|
|
428
|
+
function pageUrlOfMarkdown(url) {
|
|
429
|
+
try {
|
|
430
|
+
const parsed = new URL(url);
|
|
431
|
+
parsed.pathname = parsed.pathname.replace(/\/index\.mdx?$/i, '/').replace(/\.mdx?$/i, '') || '/';
|
|
432
|
+
return parsed.toString();
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return url;
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
async function fetchMarkdownSource(pageUrl, context) {
|
|
439
|
+
let candidate;
|
|
440
|
+
try {
|
|
441
|
+
const parsed = new URL(pageUrl);
|
|
442
|
+
parsed.search = '';
|
|
443
|
+
parsed.hash = '';
|
|
444
|
+
parsed.pathname = parsed.pathname === '/' ? '/index.md' : `${parsed.pathname.replace(/\/+$/, '')}.md`;
|
|
445
|
+
candidate = parsed.toString();
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
return undefined;
|
|
449
|
+
}
|
|
450
|
+
try {
|
|
451
|
+
const result = await fetchText(candidate, context, MAX_PAGE_BYTES, 'text/markdown, text/plain;q=0.9');
|
|
452
|
+
if (result.status >= 400 || !isMarkdownResponse(result.url, result.contentType))
|
|
453
|
+
return undefined;
|
|
454
|
+
return result.body.trim().startsWith('<') ? undefined : result.body;
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
async function fetchPage(url, context) {
|
|
461
|
+
return fetchText(url, context, MAX_PAGE_BYTES, 'text/html, application/xhtml+xml;q=0.9, */*;q=0.1');
|
|
462
|
+
}
|
|
463
|
+
async function fetchText(url, context, maxBytes, accept) {
|
|
464
|
+
let current = url;
|
|
465
|
+
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
|
|
466
|
+
const parsed = new URL(current);
|
|
467
|
+
await assertPublicHost(parsed, context.resolveHostname);
|
|
468
|
+
const headers = new Headers({ accept, 'user-agent': USER_AGENT, 'accept-language': 'en' });
|
|
469
|
+
if (context.cookieHeader)
|
|
470
|
+
headers.set('cookie', context.cookieHeader);
|
|
471
|
+
const signal = context.signal ? AbortSignal.any([context.signal, AbortSignal.timeout(context.timeoutMs)]) : AbortSignal.timeout(context.timeoutMs);
|
|
472
|
+
const response = await context.fetcher(current, { method: 'GET', redirect: 'manual', headers, signal });
|
|
473
|
+
if (response.status >= 300 && response.status < 400) {
|
|
474
|
+
if (redirects === MAX_REDIRECTS)
|
|
475
|
+
throw new DoxloopError(`Redirect limit exceeded for ${url}.`);
|
|
476
|
+
const location = response.headers.get('location');
|
|
477
|
+
if (!location)
|
|
478
|
+
throw new DoxloopError(`Redirect from ${current} did not include a location.`);
|
|
479
|
+
current = new URL(location, current).toString();
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const contentType = (response.headers.get('content-type') ?? '').toLowerCase();
|
|
483
|
+
if (!response.ok) {
|
|
484
|
+
try {
|
|
485
|
+
await response.body?.cancel();
|
|
486
|
+
}
|
|
487
|
+
catch { /* ignore */ }
|
|
488
|
+
return { url: current, status: response.status, contentType, body: '' };
|
|
489
|
+
}
|
|
490
|
+
const declared = Number(response.headers.get('content-length'));
|
|
491
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
492
|
+
throw new DoxloopError(`${current} exceeds the ${Math.round(maxBytes / 1024 / 1024)} MB page limit.`);
|
|
493
|
+
const body = await boundedResponseText(response, maxBytes, current);
|
|
494
|
+
return { url: current, status: response.status, contentType, body };
|
|
495
|
+
}
|
|
496
|
+
throw new DoxloopError(`Request for ${url} could not be completed.`);
|
|
497
|
+
}
|
|
498
|
+
async function boundedResponseText(response, maximum, url) {
|
|
499
|
+
if (!response.body)
|
|
500
|
+
return await response.text();
|
|
501
|
+
const reader = response.body.getReader();
|
|
502
|
+
const chunks = [];
|
|
503
|
+
let size = 0;
|
|
504
|
+
while (true) {
|
|
505
|
+
const next = await reader.read();
|
|
506
|
+
if (next.done)
|
|
507
|
+
break;
|
|
508
|
+
size += next.value.byteLength;
|
|
509
|
+
if (size > maximum) {
|
|
510
|
+
await reader.cancel();
|
|
511
|
+
throw new DoxloopError(`${url} exceeds the ${Math.round(maximum / 1024 / 1024)} MB page limit.`);
|
|
512
|
+
}
|
|
513
|
+
chunks.push(next.value);
|
|
514
|
+
}
|
|
515
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
516
|
+
}
|
|
517
|
+
async function assertPublicHost(url, resolver) {
|
|
518
|
+
const hostname = url.hostname.toLowerCase();
|
|
519
|
+
if (hostname === 'localhost' || hostname.endsWith('.localhost') || hostname.endsWith('.local')) {
|
|
520
|
+
throw new DoxloopError('Documentation site URLs cannot target localhost or private networks.');
|
|
521
|
+
}
|
|
522
|
+
const addresses = isIP(hostname) ? [hostname] : await resolver(hostname);
|
|
523
|
+
if (addresses.length === 0 || addresses.some(isPrivateAddress)) {
|
|
524
|
+
throw new DoxloopError('Documentation site URLs cannot target localhost or private networks.');
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
async function defaultResolveHostname(hostname) {
|
|
528
|
+
try {
|
|
529
|
+
return (await lookup(hostname, { all: true })).map((item) => item.address);
|
|
530
|
+
}
|
|
531
|
+
catch {
|
|
532
|
+
throw new DoxloopError(`Documentation site host could not be resolved: ${hostname}`);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
function unique(values) { return [...new Set(values)]; }
|
|
536
|
+
//# sourceMappingURL=docs-crawl.js.map
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { DocsSitePage, DocsSiteSnapshot } from './docs-crawl.js';
|
|
2
|
+
import type { DocsSiteSource, DocumentationPlan, SourceBinding } from './types.js';
|
|
3
|
+
export declare const DOCS_SITE_SNAPSHOT_FILE = "snapshot.json";
|
|
4
|
+
export declare const DOCS_SITE_INDEX_FILE = "index.md";
|
|
5
|
+
export declare const DOCS_SITE_PAGES_DIRECTORY = "pages";
|
|
6
|
+
export type DocsSiteManifestPage = Omit<DocsSitePage, 'markdown'> & {
|
|
7
|
+
file: string;
|
|
8
|
+
};
|
|
9
|
+
/** `snapshot.json`: everything in the crawl result except page bodies, which live in `pages/`. */
|
|
10
|
+
export interface DocsSiteManifest extends Omit<DocsSiteSnapshot, 'pages'> {
|
|
11
|
+
pages: DocsSiteManifestPage[];
|
|
12
|
+
}
|
|
13
|
+
export interface MaterializedDocsSite {
|
|
14
|
+
/** Absolute snapshot directory. */
|
|
15
|
+
path: string;
|
|
16
|
+
manifest: DocsSiteManifest;
|
|
17
|
+
site: DocsSiteSource;
|
|
18
|
+
}
|
|
19
|
+
/** Write a crawl result as a snapshot directory outside the documentation project. */
|
|
20
|
+
export declare function materializeDocsSiteSnapshot(root: string, name: string, snapshot: DocsSiteSnapshot): Promise<MaterializedDocsSite>;
|
|
21
|
+
/** The binding stored in project.json for a materialized snapshot. */
|
|
22
|
+
export declare function docsSiteBinding(root: string, name: string, materialized: MaterializedDocsSite, scope?: SourceBinding['scope']): SourceBinding;
|
|
23
|
+
export declare function docsSiteSource(manifest: Pick<DocsSiteManifest, 'url' | 'crawledAt' | 'totals' | 'hash' | 'generator' | 'truncated'>): DocsSiteSource;
|
|
24
|
+
export declare function docsSiteManifest(snapshot: DocsSiteSnapshot): DocsSiteManifest;
|
|
25
|
+
/** Read the manifest of a materialized `docs-site` source. */
|
|
26
|
+
export declare function readDocsSiteManifest(root: string, source: Pick<SourceBinding, 'path' | 'name'>): Promise<DocsSiteManifest>;
|
|
27
|
+
export declare function assertDocsSiteSnapshot(root: string, source: Pick<SourceBinding, 'path' | 'name'>): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* Snapshot-relative Markdown file for a crawled page path. The entry page is
|
|
30
|
+
* `pages/index.md`; nested URLs keep their folders so the agent can relate a
|
|
31
|
+
* file back to the original site structure.
|
|
32
|
+
*/
|
|
33
|
+
export declare function docsSitePageFile(pagePath: string): string;
|
|
34
|
+
export declare function docsSitePageMarkdown(page: DocsSitePage): string;
|
|
35
|
+
export declare function docsSiteIndexMarkdown(manifest: DocsSiteManifest): string;
|
|
36
|
+
/** Human-readable one-line description used by health, tables, and CLI output. */
|
|
37
|
+
export declare function describeDocsSite(site: DocsSiteSource): string;
|
|
38
|
+
/**
|
|
39
|
+
* Redirects implied by an approved plan: every existing page that a new page
|
|
40
|
+
* absorbs, when the old route differs from the new one. Written to the same
|
|
41
|
+
* redirect file page moves use, so the preview and the Doxbrix build honor
|
|
42
|
+
* them, and exported for the old host when the new site is deployed elsewhere.
|
|
43
|
+
* The site root is never redirected: the new site always serves its own
|
|
44
|
+
* navigation homepage there, and a root redirect would only send readers to a
|
|
45
|
+
* planned path the authored pages may not use.
|
|
46
|
+
*/
|
|
47
|
+
export declare function existingDocumentationRedirects(plan: Pick<DocumentationPlan, 'pages' | 'existingDocumentation'>): Record<string, string>;
|
|
48
|
+
export declare function writeExistingDocumentationRedirects(root: string, plan: Pick<DocumentationPlan, 'pages' | 'existingDocumentation'>): Promise<Record<string, string>>;
|
|
49
|
+
//# sourceMappingURL=docs-site.d.ts.map
|