docula 1.14.0 → 2.1.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/README.md +19 -5
- package/dist/docula.d.ts +8 -0
- package/dist/docula.js +527 -50
- package/package.json +22 -21
- package/templates/classic/includes/header.hbs +1 -1
- package/templates/modern/css/styles.css +264 -8
- package/templates/modern/includes/header-bar.hbs +8 -0
- package/templates/modern/includes/header.hbs +1 -1
- package/templates/modern/includes/scripts.hbs +2 -5
- package/templates/modern/includes/search.hbs +46 -0
- package/templates/modern/js/search.js +436 -0
package/dist/docula.js
CHANGED
|
@@ -9,10 +9,15 @@ import { Ecto } from "ecto";
|
|
|
9
9
|
import { Hashery } from "hashery";
|
|
10
10
|
import { Writr, Writr as Writr$1 } from "writr";
|
|
11
11
|
import { blue, bold, cyan, dim, gray, green, magenta, red, white, yellow } from "colorette";
|
|
12
|
+
import { promises } from "node:dns";
|
|
13
|
+
import net from "node:net";
|
|
14
|
+
import ipaddr from "ipaddr.js";
|
|
15
|
+
import { Agent, fetch } from "undici";
|
|
12
16
|
import { CacheableNet } from "@cacheable/net";
|
|
13
17
|
import os from "node:os";
|
|
18
|
+
import sea from "node:sea";
|
|
14
19
|
//#region package.json
|
|
15
|
-
var version = "1.
|
|
20
|
+
var version = "2.1.0";
|
|
16
21
|
var package_default = {
|
|
17
22
|
name: "docula",
|
|
18
23
|
version,
|
|
@@ -27,12 +32,12 @@ var package_default = {
|
|
|
27
32
|
"url": "git+https://github.com/jaredwray/docula.git"
|
|
28
33
|
},
|
|
29
34
|
author: "Jared Wray <me@jaredwray.com>",
|
|
30
|
-
engines: { "node": ">=
|
|
35
|
+
engines: { "node": "^22.19.0 || >=24.0.0" },
|
|
31
36
|
license: "MIT",
|
|
32
37
|
scripts: {
|
|
33
38
|
"clean": "rimraf ./dist ./coverage ./node_modules ./package-lock.json ./yarn.lock ./pnpm-lock.yaml ./site/dist",
|
|
34
39
|
"build": "pnpm generate-init-file && tsdown",
|
|
35
|
-
"build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts
|
|
40
|
+
"build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts",
|
|
36
41
|
"generate-embedded-templates": "tsx scripts/generate-embedded-templates.ts",
|
|
37
42
|
"lint": "biome check --write --error-on-warnings",
|
|
38
43
|
"lint:ci": "biome check --error-on-warnings",
|
|
@@ -69,39 +74,41 @@ var package_default = {
|
|
|
69
74
|
],
|
|
70
75
|
bin: { "docula": "./bin/docula.js" },
|
|
71
76
|
dependencies: {
|
|
72
|
-
"@ai-sdk/anthropic": "^3.0.
|
|
73
|
-
"@ai-sdk/google": "^3.0.
|
|
74
|
-
"@ai-sdk/openai": "^3.0.
|
|
75
|
-
"@cacheable/net": "^2.0.
|
|
76
|
-
"ai": "^6.0.
|
|
77
|
+
"@ai-sdk/anthropic": "^3.0.83",
|
|
78
|
+
"@ai-sdk/google": "^3.0.81",
|
|
79
|
+
"@ai-sdk/openai": "^3.0.70",
|
|
80
|
+
"@cacheable/net": "^2.0.8",
|
|
81
|
+
"ai": "^6.0.202",
|
|
77
82
|
"colorette": "^2.0.20",
|
|
78
|
-
"ecto": "^4.8.
|
|
79
|
-
"hashery": "^
|
|
80
|
-
"
|
|
83
|
+
"ecto": "^4.8.7",
|
|
84
|
+
"hashery": "^3.0.0",
|
|
85
|
+
"ipaddr.js": "2.4.0",
|
|
86
|
+
"jiti": "^2.7.0",
|
|
81
87
|
"serve-handler": "^6.1.7",
|
|
88
|
+
"undici": "8.4.1",
|
|
82
89
|
"update-notifier": "^7.3.1",
|
|
83
|
-
"writr": "^6.1.
|
|
90
|
+
"writr": "^6.1.2"
|
|
84
91
|
},
|
|
85
92
|
devDependencies: {
|
|
86
|
-
"@biomejs/biome": "^2.4.
|
|
87
|
-
"@playwright/test": "^1.
|
|
88
|
-
"@types/node": "^
|
|
93
|
+
"@biomejs/biome": "^2.4.16",
|
|
94
|
+
"@playwright/test": "^1.60.0",
|
|
95
|
+
"@types/node": "^24.13.2",
|
|
89
96
|
"@types/serve-handler": "^6.1.4",
|
|
90
97
|
"@types/update-notifier": "^6.0.8",
|
|
91
|
-
"@vitest/coverage-v8": "^4.1.
|
|
98
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
92
99
|
"dotenv": "^17.4.2",
|
|
93
|
-
"postject": "1.0.0-alpha.6",
|
|
94
100
|
"rimraf": "^6.1.3",
|
|
95
|
-
"tsdown": "^0.
|
|
96
|
-
"tsx": "^4.
|
|
97
|
-
"typescript": "^6.0.
|
|
98
|
-
"vitest": "^4.1.
|
|
101
|
+
"tsdown": "^0.22.2",
|
|
102
|
+
"tsx": "^4.22.4",
|
|
103
|
+
"typescript": "^6.0.3",
|
|
104
|
+
"vitest": "^4.1.8"
|
|
99
105
|
},
|
|
100
106
|
files: [
|
|
101
107
|
"dist",
|
|
102
108
|
"templates",
|
|
103
109
|
"bin"
|
|
104
|
-
]
|
|
110
|
+
],
|
|
111
|
+
packageManager: "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d"
|
|
105
112
|
};
|
|
106
113
|
//#endregion
|
|
107
114
|
//#region src/builder-ai.ts
|
|
@@ -118,15 +125,15 @@ async function createAIModel(ai) {
|
|
|
118
125
|
switch (ai.provider) {
|
|
119
126
|
case "anthropic": {
|
|
120
127
|
const { createAnthropic } = await import("@ai-sdk/anthropic");
|
|
121
|
-
return createAnthropic({ apiKey: ai.apiKey })(ai.model
|
|
128
|
+
return createAnthropic(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "claude-haiku-4-5");
|
|
122
129
|
}
|
|
123
130
|
case "openai": {
|
|
124
131
|
const { createOpenAI } = await import("@ai-sdk/openai");
|
|
125
|
-
return createOpenAI({ apiKey: ai.apiKey })(ai.model
|
|
132
|
+
return createOpenAI(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "gpt-4o-mini");
|
|
126
133
|
}
|
|
127
134
|
case "google": {
|
|
128
135
|
const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
|
|
129
|
-
return createGoogleGenerativeAI({ apiKey: ai.apiKey })(ai.model
|
|
136
|
+
return createGoogleGenerativeAI(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "gemini-2.5-flash-lite");
|
|
130
137
|
}
|
|
131
138
|
default: return;
|
|
132
139
|
}
|
|
@@ -303,7 +310,10 @@ function logChangelogMetadata(console, name, metadata, fromCache) {
|
|
|
303
310
|
return;
|
|
304
311
|
}
|
|
305
312
|
console.info(`AI enriched changelog: ${name}`);
|
|
306
|
-
if (metadata.preview || metadata.summary) console.log(white(
|
|
313
|
+
if (metadata.preview || metadata.summary) console.log(white(
|
|
314
|
+
/* v8 ignore next -- the enclosing `if` guarantees preview or summary is truthy, so the `|| ""` fallback is unreachable; it only satisfies truncate's string parameter type -- @preserve */
|
|
315
|
+
` preview: ${truncate(metadata.preview || metadata.summary || "")}`
|
|
316
|
+
));
|
|
307
317
|
if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
|
|
308
318
|
if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
|
|
309
319
|
}
|
|
@@ -597,6 +607,7 @@ function extractResponses(operation, spec) {
|
|
|
597
607
|
responses.push({
|
|
598
608
|
statusCode,
|
|
599
609
|
statusClass: getStatusClass(statusCode),
|
|
610
|
+
/* v8 ignore next */
|
|
600
611
|
description: response.description ?? "",
|
|
601
612
|
contentType,
|
|
602
613
|
schemaProperties,
|
|
@@ -882,6 +893,294 @@ async function buildRobotsPage(options) {
|
|
|
882
893
|
await (fs.existsSync(`${sitePath}/robots.txt`) ? fs.promises.copyFile(`${sitePath}/robots.txt`, robotsPath) : fs.promises.writeFile(robotsPath, "User-agent: *\nDisallow:"));
|
|
883
894
|
}
|
|
884
895
|
//#endregion
|
|
896
|
+
//#region src/safe-fetch.ts
|
|
897
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
898
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
899
|
+
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
900
|
+
const MAX_URL_LENGTH = 8192;
|
|
901
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
902
|
+
"localhost",
|
|
903
|
+
"ip6-localhost",
|
|
904
|
+
"ip6-loopback",
|
|
905
|
+
"localhost.localdomain"
|
|
906
|
+
]);
|
|
907
|
+
const BLOCKED_IPV4_RANGES = new Set([
|
|
908
|
+
"unspecified",
|
|
909
|
+
"broadcast",
|
|
910
|
+
"multicast",
|
|
911
|
+
"linkLocal",
|
|
912
|
+
"loopback",
|
|
913
|
+
"carrierGradeNat",
|
|
914
|
+
"private",
|
|
915
|
+
"reserved",
|
|
916
|
+
"as112",
|
|
917
|
+
"amt"
|
|
918
|
+
]);
|
|
919
|
+
const BLOCKED_IPV6_RANGES = new Set([
|
|
920
|
+
"unspecified",
|
|
921
|
+
"linkLocal",
|
|
922
|
+
"multicast",
|
|
923
|
+
"loopback",
|
|
924
|
+
"uniqueLocal",
|
|
925
|
+
"discard",
|
|
926
|
+
"rfc6145",
|
|
927
|
+
"rfc6052",
|
|
928
|
+
"teredo",
|
|
929
|
+
"benchmarking",
|
|
930
|
+
"amt",
|
|
931
|
+
"as112v6",
|
|
932
|
+
"reserved"
|
|
933
|
+
]);
|
|
934
|
+
var SsrfBlockedError = class extends Error {
|
|
935
|
+
constructor(message) {
|
|
936
|
+
super(message);
|
|
937
|
+
this.name = "SsrfBlockedError";
|
|
938
|
+
}
|
|
939
|
+
};
|
|
940
|
+
/**
|
|
941
|
+
* Returns true if `ip` is NOT publicly routable.
|
|
942
|
+
*
|
|
943
|
+
* IPv4-mapped (`::ffff:a.b.c.d`), 6to4 (`2002:hh:hh::/16`), and the deprecated
|
|
944
|
+
* IPv4-compatible (`::a.b.c.d`) IPv6 forms all unwrap to an embedded IPv4
|
|
945
|
+
* address and are re-validated against the IPv4 rules. Writing a private IPv4
|
|
946
|
+
* in any of these IPv6 spellings — including the hex form `::ffff:7f00:1` —
|
|
947
|
+
* therefore does not bypass the IPv4 deny-list.
|
|
948
|
+
*
|
|
949
|
+
* Failure-closed: malformed input is treated as private.
|
|
950
|
+
*/
|
|
951
|
+
function isPrivateIp(ip) {
|
|
952
|
+
if (!ipaddr.isValid(ip)) return true;
|
|
953
|
+
const parsed = ipaddr.parse(ip);
|
|
954
|
+
if (parsed.kind() === "ipv4") return BLOCKED_IPV4_RANGES.has(parsed.range());
|
|
955
|
+
const v6 = parsed;
|
|
956
|
+
const range = v6.range();
|
|
957
|
+
if (range === "ipv4Mapped") return isPrivateIp(v6.toIPv4Address().toString());
|
|
958
|
+
if (range === "6to4") {
|
|
959
|
+
const parts = v6.parts;
|
|
960
|
+
/* v8 ignore stop */
|
|
961
|
+
return isPrivateIp(`${(parts[1] ?? 0) >> 8}.${(parts[1] ?? 0) & 255}.${(parts[2] ?? 0) >> 8}.${(parts[2] ?? 0) & 255}`);
|
|
962
|
+
}
|
|
963
|
+
if (range !== "unicast") return BLOCKED_IPV6_RANGES.has(range);
|
|
964
|
+
const parts = v6.parts;
|
|
965
|
+
if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0 && !(parts[6] === 0 && parts[7] === 0))
|
|
966
|
+
/* v8 ignore stop */
|
|
967
|
+
return isPrivateIp(`${(parts[6] ?? 0) >> 8}.${(parts[6] ?? 0) & 255}.${(parts[7] ?? 0) >> 8}.${(parts[7] ?? 0) & 255}`);
|
|
968
|
+
return false;
|
|
969
|
+
}
|
|
970
|
+
/**
|
|
971
|
+
* Validates `url` and, for hostname-based URLs, resolves and validates every
|
|
972
|
+
* returned IP. Throws SsrfBlockedError if the URL is unparseable, uses a
|
|
973
|
+
* non-http(s) scheme, embeds credentials, targets a blocked hostname like
|
|
974
|
+
* `localhost`, or resolves to any non-publicly-routable IP. Returns the
|
|
975
|
+
* parsed URL and the validated addresses so the caller can pin them at
|
|
976
|
+
* connect time and close the DNS-rebinding TOCTOU window.
|
|
977
|
+
*/
|
|
978
|
+
async function resolveAndValidate(url, options = {}) {
|
|
979
|
+
if (url.length > MAX_URL_LENGTH) throw new SsrfBlockedError(`URL exceeds ${MAX_URL_LENGTH} characters (got ${url.length})`);
|
|
980
|
+
let parsed;
|
|
981
|
+
try {
|
|
982
|
+
parsed = new URL(url);
|
|
983
|
+
} catch {
|
|
984
|
+
throw new SsrfBlockedError(`Invalid URL: ${url}`);
|
|
985
|
+
}
|
|
986
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SsrfBlockedError(`Only http(s) URLs are allowed (got ${parsed.protocol})`);
|
|
987
|
+
if (parsed.username || parsed.password) throw new SsrfBlockedError("Refusing to fetch URL with embedded credentials");
|
|
988
|
+
const rawHostname = parsed.hostname;
|
|
989
|
+
/* v8 ignore next 3 -- @preserve */
|
|
990
|
+
if (!rawHostname) throw new SsrfBlockedError("URL has no hostname");
|
|
991
|
+
const hostname = rawHostname.startsWith("[") && rawHostname.endsWith("]") ? rawHostname.slice(1, -1) : rawHostname;
|
|
992
|
+
const lowerHost = hostname.toLowerCase();
|
|
993
|
+
if (BLOCKED_HOSTNAMES.has(lowerHost) || lowerHost.endsWith(".localhost") || lowerHost.endsWith(".localhost.localdomain")) throw new SsrfBlockedError(`Refusing to fetch from ${hostname}`);
|
|
994
|
+
if (net.isIP(hostname)) {
|
|
995
|
+
if (isPrivateIp(hostname)) throw new SsrfBlockedError(`Refusing to fetch from non-public IP ${hostname}`);
|
|
996
|
+
return {
|
|
997
|
+
urlObj: parsed,
|
|
998
|
+
resolved: [{
|
|
999
|
+
address: hostname,
|
|
1000
|
+
family: net.isIPv6(hostname) ? 6 : 4
|
|
1001
|
+
}]
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
/* v8 ignore next -- the `?? dns.lookup` default invokes real DNS; only reachable without an injected lookup -- @preserve */
|
|
1005
|
+
const lookupFn = options.lookup ?? promises.lookup;
|
|
1006
|
+
let addresses;
|
|
1007
|
+
try {
|
|
1008
|
+
addresses = await lookupFn(hostname, {
|
|
1009
|
+
all: true,
|
|
1010
|
+
verbatim: true
|
|
1011
|
+
});
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
throw new SsrfBlockedError(`DNS lookup for ${hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (addresses.length === 0) throw new SsrfBlockedError(`DNS lookup for ${hostname} returned no addresses`);
|
|
1016
|
+
for (const { address } of addresses) if (isPrivateIp(address)) throw new SsrfBlockedError(`Refusing to fetch from ${hostname} — resolves to non-public IP ${address}`);
|
|
1017
|
+
return {
|
|
1018
|
+
urlObj: parsed,
|
|
1019
|
+
resolved: addresses.map((a) => ({
|
|
1020
|
+
address: a.address,
|
|
1021
|
+
family: a.family === 6 ? 6 : 4
|
|
1022
|
+
}))
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
/**
|
|
1026
|
+
* Builds a `net.lookup`-shaped callback that always returns the pre-validated
|
|
1027
|
+
* `resolved` addresses, ignoring the hostname argument. Supports both call
|
|
1028
|
+
* signatures: when `opts.all` is truthy the callback receives the full array;
|
|
1029
|
+
* otherwise (the shape Node uses when `autoSelectFamily` is disabled) the
|
|
1030
|
+
* callback receives a single `address, family` pair.
|
|
1031
|
+
*/
|
|
1032
|
+
function pinnedLookup(resolved) {
|
|
1033
|
+
return (_hostname, opts, callback) => {
|
|
1034
|
+
if (opts.all) {
|
|
1035
|
+
callback(null, resolved);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const first = resolved[0];
|
|
1039
|
+
/* v8 ignore next 4 -- resolved is non-empty by construction in safeFetch -- @preserve */
|
|
1040
|
+
if (!first) {
|
|
1041
|
+
callback(/* @__PURE__ */ new Error("No pinned address available"));
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
callback(null, first.address, first.family);
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* fetch wrapper hardened against SSRF:
|
|
1049
|
+
* - Scheme allow-list (http/https), no embedded credentials, no localhost.
|
|
1050
|
+
* - The resolved IP is pinned to undici's TCP connect via a custom Agent
|
|
1051
|
+
* `lookup`, so fetch does not re-resolve the hostname. This closes the
|
|
1052
|
+
* DNS-rebinding TOCTOU window between validation and connect. TLS SNI
|
|
1053
|
+
* and certificate validation still use the original hostname because
|
|
1054
|
+
* undici's connector passes `host: hostname` to `tls.connect` regardless
|
|
1055
|
+
* of what `lookup` returns; if that internal contract changes in a future
|
|
1056
|
+
* undici release, this wrapper's TLS-hostname guarantee will need to be
|
|
1057
|
+
* re-checked.
|
|
1058
|
+
* - Redirects are followed manually; each target is re-validated and the
|
|
1059
|
+
* new connect is re-pinned. The previous hop's body is cancelled before
|
|
1060
|
+
* moving on so the connection is released instead of being held open.
|
|
1061
|
+
* - `timeoutMs` bounds the total wall-clock budget — the AbortController
|
|
1062
|
+
* survives until the returned body is fully consumed (or errors), so a
|
|
1063
|
+
* slow-drip body cannot bypass the deadline.
|
|
1064
|
+
* - `maxBodyBytes` caps the response body up-front via `content-length`
|
|
1065
|
+
* (when it parses cleanly as a decimal integer) and during streaming.
|
|
1066
|
+
* - URLs over `MAX_URL_LENGTH` are rejected before parsing.
|
|
1067
|
+
* - Per-hop Agents are `destroy()`ed (not gracefully `close()`d) on error
|
|
1068
|
+
* or after the body is consumed, so a malicious server cannot leak
|
|
1069
|
+
* sockets by holding a redirected body open.
|
|
1070
|
+
*/
|
|
1071
|
+
async function safeFetch(url, options = {}) {
|
|
1072
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
1073
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1074
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
1075
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1076
|
+
const lookup = options.lookup;
|
|
1077
|
+
const totalController = new AbortController();
|
|
1078
|
+
const totalTimer = setTimeout(() => totalController.abort(), timeoutMs);
|
|
1079
|
+
const dispatchers = [];
|
|
1080
|
+
let cleaned = false;
|
|
1081
|
+
const cleanup = () => {
|
|
1082
|
+
if (cleaned) return;
|
|
1083
|
+
cleaned = true;
|
|
1084
|
+
clearTimeout(totalTimer);
|
|
1085
|
+
for (const d of dispatchers)
|
|
1086
|
+
/* v8 ignore next -- the .catch arrow only runs if undici Agent.destroy() rejects, which it does not in practice -- @preserve */
|
|
1087
|
+
d.destroy().catch(() => {});
|
|
1088
|
+
};
|
|
1089
|
+
let currentUrl = url;
|
|
1090
|
+
try {
|
|
1091
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1092
|
+
const { resolved } = await resolveAndValidate(currentUrl, { lookup });
|
|
1093
|
+
const dispatcher = new Agent({ connect: { lookup: pinnedLookup(resolved) } });
|
|
1094
|
+
dispatchers.push(dispatcher);
|
|
1095
|
+
const response = await fetchImpl(currentUrl, {
|
|
1096
|
+
dispatcher,
|
|
1097
|
+
redirect: "manual",
|
|
1098
|
+
signal: totalController.signal
|
|
1099
|
+
});
|
|
1100
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1101
|
+
const location = response.headers.get("location");
|
|
1102
|
+
if (location) {
|
|
1103
|
+
await response.body?.cancel().catch(() => {});
|
|
1104
|
+
/* v8 ignore next -- the .catch arrow only runs if undici Agent.destroy() rejects, which it does not in practice -- @preserve */
|
|
1105
|
+
dispatcher.destroy().catch(() => {});
|
|
1106
|
+
dispatchers.pop();
|
|
1107
|
+
let nextUrl;
|
|
1108
|
+
try {
|
|
1109
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
1110
|
+
} catch {
|
|
1111
|
+
throw new SsrfBlockedError(`Invalid redirect Location header (${location}) from ${currentUrl}`);
|
|
1112
|
+
}
|
|
1113
|
+
currentUrl = nextUrl;
|
|
1114
|
+
continue;
|
|
1115
|
+
}
|
|
1116
|
+
return capResponseBody(response, maxBodyBytes, cleanup);
|
|
1117
|
+
}
|
|
1118
|
+
return capResponseBody(response, maxBodyBytes, cleanup);
|
|
1119
|
+
}
|
|
1120
|
+
throw new SsrfBlockedError(`Too many redirects (>${maxRedirects}) starting at ${url}`);
|
|
1121
|
+
} catch (error) {
|
|
1122
|
+
cleanup();
|
|
1123
|
+
throw error;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
function capResponseBody(response, maxBytes, onDone) {
|
|
1127
|
+
const contentLength = response.headers.get("content-length");
|
|
1128
|
+
if (contentLength && /^\d+$/.test(contentLength)) {
|
|
1129
|
+
const n = Number.parseInt(contentLength, 10);
|
|
1130
|
+
if (n > maxBytes) {
|
|
1131
|
+
onDone();
|
|
1132
|
+
throw new SsrfBlockedError(`Response body exceeds ${maxBytes} bytes (content-length: ${n})`);
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
if (!response.body) {
|
|
1136
|
+
onDone();
|
|
1137
|
+
return response;
|
|
1138
|
+
}
|
|
1139
|
+
const reader = response.body.getReader();
|
|
1140
|
+
let received = 0;
|
|
1141
|
+
let finalized = false;
|
|
1142
|
+
const finalize = () => {
|
|
1143
|
+
/* v8 ignore next -- double-call guard; each finalize caller settles the stream, so finalize cannot deterministically run twice -- @preserve */
|
|
1144
|
+
if (finalized) return;
|
|
1145
|
+
finalized = true;
|
|
1146
|
+
onDone();
|
|
1147
|
+
};
|
|
1148
|
+
const cappedStream = new ReadableStream({
|
|
1149
|
+
async pull(controller) {
|
|
1150
|
+
try {
|
|
1151
|
+
const { value, done } = await reader.read();
|
|
1152
|
+
if (done) {
|
|
1153
|
+
controller.close();
|
|
1154
|
+
finalize();
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
received += value.byteLength;
|
|
1158
|
+
if (received > maxBytes) {
|
|
1159
|
+
controller.error(new SsrfBlockedError(`Response body exceeds ${maxBytes} bytes (streamed)`));
|
|
1160
|
+
reader.cancel().catch(() => {});
|
|
1161
|
+
finalize();
|
|
1162
|
+
return;
|
|
1163
|
+
}
|
|
1164
|
+
controller.enqueue(value);
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
controller.error(error);
|
|
1167
|
+
finalize();
|
|
1168
|
+
}
|
|
1169
|
+
/* v8 ignore stop */
|
|
1170
|
+
},
|
|
1171
|
+
/* v8 ignore next 4 -- only invoked when the consumer cancels the body stream -- @preserve */
|
|
1172
|
+
cancel() {
|
|
1173
|
+
reader.cancel().catch(() => {});
|
|
1174
|
+
finalize();
|
|
1175
|
+
}
|
|
1176
|
+
});
|
|
1177
|
+
return new Response(cappedStream, {
|
|
1178
|
+
status: response.status,
|
|
1179
|
+
statusText: response.statusText,
|
|
1180
|
+
headers: response.headers
|
|
1181
|
+
});
|
|
1182
|
+
}
|
|
1183
|
+
//#endregion
|
|
885
1184
|
//#region src/builder-api.ts
|
|
886
1185
|
const writrOptions$4 = {
|
|
887
1186
|
throwOnEmitError: false,
|
|
@@ -911,8 +1210,10 @@ async function getSafeSiteOverrideFileContent(sitePath, fileName) {
|
|
|
911
1210
|
realSitePath = await fs.promises.realpath(resolvedSitePath);
|
|
912
1211
|
realCandidatePath = await fs.promises.realpath(candidatePath);
|
|
913
1212
|
} catch {
|
|
1213
|
+
/* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
|
|
914
1214
|
return;
|
|
915
1215
|
}
|
|
1216
|
+
/* v8 ignore next 3 -- llms override uses a fixed single-component filename, so no intermediate symlink can make a regular file's realpath escape the (consistently resolved) site path; defensive TOCTOU guard -- @preserve */
|
|
916
1217
|
if (!isPathWithinBasePath(realCandidatePath, realSitePath)) return;
|
|
917
1218
|
return fs.promises.readFile(realCandidatePath, "utf8");
|
|
918
1219
|
}
|
|
@@ -948,6 +1249,7 @@ async function getSafeLocalOpenApiSpecForSpec(data, specUrl) {
|
|
|
948
1249
|
realSitePath = await fs.promises.realpath(resolvedSitePath);
|
|
949
1250
|
realLocalPath = await fs.promises.realpath(resolvedLocalPath);
|
|
950
1251
|
} catch {
|
|
1252
|
+
/* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
|
|
951
1253
|
return;
|
|
952
1254
|
}
|
|
953
1255
|
if (!isPathWithinBasePath(realLocalPath, realSitePath)) return;
|
|
@@ -967,7 +1269,7 @@ async function parseAndRenderSpec(data, specUrl) {
|
|
|
967
1269
|
const localSpec = await getSafeLocalOpenApiSpecForSpec(data, specUrl);
|
|
968
1270
|
if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
|
|
969
1271
|
else if (isRemoteUrl(specUrl)) try {
|
|
970
|
-
apiSpec = parseOpenApiSpec(await (await
|
|
1272
|
+
apiSpec = parseOpenApiSpec(await (await safeFetch(specUrl)).text());
|
|
971
1273
|
} catch {}
|
|
972
1274
|
/* v8 ignore next -- @preserve */
|
|
973
1275
|
if (apiSpec) {
|
|
@@ -1022,6 +1324,7 @@ async function buildAllApiPages(ecto, data) {
|
|
|
1022
1324
|
async function renderApiContent(ecto, data) {
|
|
1023
1325
|
const firstSpecUrl = data.openApiSpecs?.[0]?.url;
|
|
1024
1326
|
if (!firstSpecUrl || !data.templates?.api) throw new Error("No API template or openApiUrl found");
|
|
1327
|
+
/* v8 ignore next -- a truthy firstSpecUrl guarantees a non-empty openApiSpecs, so the false branch is unreachable; the legacy fallback below stays for defense-in-depth -- @preserve */
|
|
1025
1328
|
if (data.openApiSpecs && data.openApiSpecs.length > 0) return renderCombinedApiContent(ecto, data);
|
|
1026
1329
|
/* v8 ignore start -- @preserve */
|
|
1027
1330
|
const swaggerSource = `${data.sitePath}/api/swagger.json`;
|
|
@@ -1032,7 +1335,7 @@ async function renderApiContent(ecto, data) {
|
|
|
1032
1335
|
const localSpec = await getSafeLocalOpenApiSpec(data);
|
|
1033
1336
|
if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
|
|
1034
1337
|
else if (firstSpecUrl && isRemoteUrl(firstSpecUrl)) try {
|
|
1035
|
-
apiSpec = parseOpenApiSpec(await (await
|
|
1338
|
+
apiSpec = parseOpenApiSpec(await (await safeFetch(firstSpecUrl)).text());
|
|
1036
1339
|
} catch {}
|
|
1037
1340
|
if (apiSpec) {
|
|
1038
1341
|
apiSpec.info.description = new Writr$1(apiSpec.info.description, writrOptions$4).renderSync();
|
|
@@ -1118,6 +1421,7 @@ function hashOptions(hash, options) {
|
|
|
1118
1421
|
template: options.template,
|
|
1119
1422
|
templatePath: options.templatePath,
|
|
1120
1423
|
enableLlmsTxt: options.enableLlmsTxt,
|
|
1424
|
+
enableSearch: options.enableSearch,
|
|
1121
1425
|
changelogPerPage: options.changelogPerPage,
|
|
1122
1426
|
enableReleaseChangelog: options.enableReleaseChangelog,
|
|
1123
1427
|
sections: options.sections,
|
|
@@ -1171,6 +1475,7 @@ function hasAssetsChanged(hash, sitePath, previousAssets, autoReadme) {
|
|
|
1171
1475
|
for (const file of [
|
|
1172
1476
|
"favicon.ico",
|
|
1173
1477
|
"logo.svg",
|
|
1478
|
+
"logo.png",
|
|
1174
1479
|
"logo_horizontal.png",
|
|
1175
1480
|
"variables.css",
|
|
1176
1481
|
"api/swagger.json",
|
|
@@ -2109,6 +2414,153 @@ async function generateLlmsFullContent(data) {
|
|
|
2109
2414
|
return lines.join("\n");
|
|
2110
2415
|
}
|
|
2111
2416
|
//#endregion
|
|
2417
|
+
//#region src/builder-search.ts
|
|
2418
|
+
/**
|
|
2419
|
+
* Name of the JSON search index written to the output root. The client-side
|
|
2420
|
+
* search script fetches this file (relative to {@link DoculaData.baseUrl}) to
|
|
2421
|
+
* power the search modal.
|
|
2422
|
+
*/
|
|
2423
|
+
const SEARCH_INDEX_FILENAME = "search-index.json";
|
|
2424
|
+
const NAMED_ENTITIES = {
|
|
2425
|
+
amp: "&",
|
|
2426
|
+
lt: "<",
|
|
2427
|
+
gt: ">",
|
|
2428
|
+
quot: "\"",
|
|
2429
|
+
apos: "'",
|
|
2430
|
+
nbsp: " ",
|
|
2431
|
+
copy: "©",
|
|
2432
|
+
reg: "®",
|
|
2433
|
+
trade: "™",
|
|
2434
|
+
hellip: "…",
|
|
2435
|
+
mdash: "—",
|
|
2436
|
+
ndash: "–",
|
|
2437
|
+
lsquo: "‘",
|
|
2438
|
+
rsquo: "’",
|
|
2439
|
+
ldquo: "“",
|
|
2440
|
+
rdquo: "”"
|
|
2441
|
+
};
|
|
2442
|
+
const ENTITY_REGEX = /&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
2443
|
+
const HEADING_REGEX = /<h([1-6])\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
2444
|
+
/**
|
|
2445
|
+
* Decode the small set of HTML entities that can appear in rendered markdown
|
|
2446
|
+
* (named, decimal, and hexadecimal). Unknown named entities are left intact.
|
|
2447
|
+
*/
|
|
2448
|
+
function decodeEntities(text) {
|
|
2449
|
+
return text.replace(ENTITY_REGEX, (match, entity) => {
|
|
2450
|
+
if (entity[0] === "#") {
|
|
2451
|
+
const codePoint = entity[1] === "x" || entity[1] === "X" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
2452
|
+
if (Number.isNaN(codePoint)) return match;
|
|
2453
|
+
try {
|
|
2454
|
+
return String.fromCodePoint(codePoint);
|
|
2455
|
+
} catch {
|
|
2456
|
+
return match;
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
return NAMED_ENTITIES[entity.toLowerCase()] ?? match;
|
|
2460
|
+
});
|
|
2461
|
+
}
|
|
2462
|
+
/**
|
|
2463
|
+
* Convert an HTML fragment into collapsed, decoded plain text. Script and
|
|
2464
|
+
* style blocks are dropped entirely before tags are stripped.
|
|
2465
|
+
*/
|
|
2466
|
+
function stripHtml(html) {
|
|
2467
|
+
if (!html) return "";
|
|
2468
|
+
return decodeEntities(html.replace(/<!--[\s\S]*?-->/g, " ").replace(/<script\b[\s\S]*?<\/script[^>]*>/gi, " ").replace(/<style\b[\s\S]*?<\/style[^>]*>/gi, " ").replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
|
|
2469
|
+
}
|
|
2470
|
+
/**
|
|
2471
|
+
* Remove a trailing `index.html` so urls point at the clean directory path
|
|
2472
|
+
* that the static site actually serves.
|
|
2473
|
+
*/
|
|
2474
|
+
function stripIndexHtml(urlPath) {
|
|
2475
|
+
return urlPath.replace(/index\.html$/, "");
|
|
2476
|
+
}
|
|
2477
|
+
function isTableOfContents(anchor, title) {
|
|
2478
|
+
return anchor === "table-of-contents" || title.trim().toLowerCase() === "table of contents";
|
|
2479
|
+
}
|
|
2480
|
+
/**
|
|
2481
|
+
* Split a rendered HTML page into search records: one for the page intro
|
|
2482
|
+
* (content before the first heading) plus one per heading section. The
|
|
2483
|
+
* injected "Table of Contents" section is skipped.
|
|
2484
|
+
*/
|
|
2485
|
+
function extractSections(html, pageTitle, pageUrl) {
|
|
2486
|
+
const source = html ?? "";
|
|
2487
|
+
const records = [];
|
|
2488
|
+
const headings = [];
|
|
2489
|
+
HEADING_REGEX.lastIndex = 0;
|
|
2490
|
+
let match = HEADING_REGEX.exec(source);
|
|
2491
|
+
while (match !== null) {
|
|
2492
|
+
headings.push({
|
|
2493
|
+
level: Number.parseInt(match[1], 10),
|
|
2494
|
+
anchor: match[2],
|
|
2495
|
+
title: stripHtml(match[3]),
|
|
2496
|
+
headingStart: match.index,
|
|
2497
|
+
contentStart: match.index + match[0].length
|
|
2498
|
+
});
|
|
2499
|
+
match = HEADING_REGEX.exec(source);
|
|
2500
|
+
}
|
|
2501
|
+
const introEnd = headings.length > 0 ? headings[0].headingStart : source.length;
|
|
2502
|
+
records.push({
|
|
2503
|
+
id: pageUrl,
|
|
2504
|
+
title: pageTitle,
|
|
2505
|
+
titles: [],
|
|
2506
|
+
text: stripHtml(source.slice(0, introEnd)),
|
|
2507
|
+
url: pageUrl
|
|
2508
|
+
});
|
|
2509
|
+
const stack = [];
|
|
2510
|
+
for (let index = 0; index < headings.length; index++) {
|
|
2511
|
+
const heading = headings[index];
|
|
2512
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) stack.pop();
|
|
2513
|
+
if (isTableOfContents(heading.anchor, heading.title)) continue;
|
|
2514
|
+
const next = headings[index + 1];
|
|
2515
|
+
const sectionEnd = next ? next.headingStart : source.length;
|
|
2516
|
+
const url = `${pageUrl}#${heading.anchor}`;
|
|
2517
|
+
records.push({
|
|
2518
|
+
id: url,
|
|
2519
|
+
title: heading.title,
|
|
2520
|
+
titles: [pageTitle, ...stack.map((item) => item.title)],
|
|
2521
|
+
text: stripHtml(source.slice(heading.contentStart, sectionEnd)),
|
|
2522
|
+
url
|
|
2523
|
+
});
|
|
2524
|
+
stack.push({
|
|
2525
|
+
level: heading.level,
|
|
2526
|
+
title: heading.title
|
|
2527
|
+
});
|
|
2528
|
+
}
|
|
2529
|
+
return records;
|
|
2530
|
+
}
|
|
2531
|
+
/**
|
|
2532
|
+
* Build the full list of search records for a site from its documents and
|
|
2533
|
+
* (published) changelog entries.
|
|
2534
|
+
*/
|
|
2535
|
+
function generateSearchRecords(data) {
|
|
2536
|
+
const records = [];
|
|
2537
|
+
for (const document of data.documents ?? []) {
|
|
2538
|
+
const pageTitle = document.navTitle || document.title || "Untitled";
|
|
2539
|
+
const url = `${data.baseUrl}${stripIndexHtml(document.urlPath)}`;
|
|
2540
|
+
records.push(...extractSections(document.generatedHtml, pageTitle, url));
|
|
2541
|
+
}
|
|
2542
|
+
for (const entry of data.changelogEntries ?? []) {
|
|
2543
|
+
if (entry.draft) continue;
|
|
2544
|
+
const pageTitle = entry.title || "Untitled";
|
|
2545
|
+
const url = stripIndexHtml(entry.urlPath);
|
|
2546
|
+
records.push(...extractSections(entry.generatedHtml, pageTitle, url));
|
|
2547
|
+
}
|
|
2548
|
+
return records;
|
|
2549
|
+
}
|
|
2550
|
+
/**
|
|
2551
|
+
* Generate the search index JSON for the build when search is enabled. The
|
|
2552
|
+
* file is written to the output root and consumed by the client-side search.
|
|
2553
|
+
*/
|
|
2554
|
+
async function buildSearchIndex(console, data) {
|
|
2555
|
+
if (!data.enableSearch) return;
|
|
2556
|
+
console.step("Building search index...");
|
|
2557
|
+
await fs.promises.mkdir(data.output, { recursive: true });
|
|
2558
|
+
const records = generateSearchRecords(data);
|
|
2559
|
+
const outputPath = `${data.output}/${SEARCH_INDEX_FILENAME}`;
|
|
2560
|
+
await fs.promises.writeFile(outputPath, JSON.stringify({ records }), "utf8");
|
|
2561
|
+
console.fileBuilt(SEARCH_INDEX_FILENAME);
|
|
2562
|
+
}
|
|
2563
|
+
//#endregion
|
|
2112
2564
|
//#region src/console.ts
|
|
2113
2565
|
const ansiRegex = /\u001B\[[0-9;]*m/g;
|
|
2114
2566
|
var DoculaConsole = class {
|
|
@@ -2510,6 +2962,11 @@ var DoculaOptions = class {
|
|
|
2510
2962
|
*/
|
|
2511
2963
|
enableLlmsTxt = true;
|
|
2512
2964
|
/**
|
|
2965
|
+
* When true, generates a search index (search-index.json) and renders the
|
|
2966
|
+
* search UI in templates that support it. Set to false to disable search.
|
|
2967
|
+
*/
|
|
2968
|
+
enableSearch = true;
|
|
2969
|
+
/**
|
|
2513
2970
|
* Override the default theme toggle. By default the site follows the system
|
|
2514
2971
|
* preference. Set to "light" or "dark" to use that theme when no user
|
|
2515
2972
|
* preference is stored in localStorage.
|
|
@@ -2668,6 +3125,7 @@ var DoculaOptions = class {
|
|
|
2668
3125
|
if (options.enableReleaseChangelog !== void 0 && typeof options.enableReleaseChangelog === "boolean") this.enableReleaseChangelog = options.enableReleaseChangelog;
|
|
2669
3126
|
if (options.changelogPerPage !== void 0 && typeof options.changelogPerPage === "number" && options.changelogPerPage > 0) this.changelogPerPage = options.changelogPerPage;
|
|
2670
3127
|
if (options.enableLlmsTxt !== void 0 && typeof options.enableLlmsTxt === "boolean") this.enableLlmsTxt = options.enableLlmsTxt;
|
|
3128
|
+
if (options.enableSearch !== void 0 && typeof options.enableSearch === "boolean") this.enableSearch = options.enableSearch;
|
|
2671
3129
|
if (options.themeMode !== void 0 && (options.themeMode === "light" || options.themeMode === "dark")) this.themeMode = options.themeMode;
|
|
2672
3130
|
if (options.autoUpdateIgnores !== void 0 && typeof options.autoUpdateIgnores === "boolean") this.autoUpdateIgnores = options.autoUpdateIgnores;
|
|
2673
3131
|
if (options.autoReadme !== void 0 && typeof options.autoReadme === "boolean") this.autoReadme = options.autoReadme;
|
|
@@ -2695,12 +3153,7 @@ var DoculaOptions = class {
|
|
|
2695
3153
|
* Returns true when running as a single-executable application (SEA).
|
|
2696
3154
|
*/
|
|
2697
3155
|
function isSEA() {
|
|
2698
|
-
|
|
2699
|
-
return Boolean(process.sea);
|
|
2700
|
-
} catch {
|
|
2701
|
-
/* v8 ignore next -- @preserve */
|
|
2702
|
-
return false;
|
|
2703
|
-
}
|
|
3156
|
+
return sea.isSea();
|
|
2704
3157
|
}
|
|
2705
3158
|
/**
|
|
2706
3159
|
* Returns the deterministic temp directory path for extracted templates.
|
|
@@ -2831,6 +3284,12 @@ var DoculaBuilder = class {
|
|
|
2831
3284
|
editPageUrl: this.options.editPageUrl,
|
|
2832
3285
|
openGraph: this.options.openGraph
|
|
2833
3286
|
};
|
|
3287
|
+
const resolvedFavicon = [
|
|
3288
|
+
"favicon.ico",
|
|
3289
|
+
"logo.svg",
|
|
3290
|
+
"logo.png"
|
|
3291
|
+
].find((file) => fs.existsSync(path.join(this.options.sitePath, file)));
|
|
3292
|
+
if (resolvedFavicon) doculaData.faviconUrl = buildUrlPath(this.options.baseUrl, resolvedFavicon);
|
|
2834
3293
|
if (siteReadmeExists) currentAssetHashes["README.md"] = hashFile(this._hash, path.join(this.options.sitePath, "README.md"));
|
|
2835
3294
|
else if (autoReadmeResult) currentAssetHashes.__autoReadme = hashFile(this._hash, autoReadmeResult.sourcePath);
|
|
2836
3295
|
if (Array.isArray(this.options.openApiUrl)) doculaData.openApiSpecs = this.options.openApiUrl.map((spec) => ({
|
|
@@ -2896,6 +3355,7 @@ var DoculaBuilder = class {
|
|
|
2896
3355
|
});
|
|
2897
3356
|
doculaData.changelogEntries = allChangelogEntries;
|
|
2898
3357
|
doculaData.hasChangelog = allChangelogEntries.length > 0 && hasChangelogTemplate;
|
|
3358
|
+
doculaData.enableSearch = Boolean(this.options.enableSearch && (doculaData.hasDocuments || doculaData.hasChangelog));
|
|
2899
3359
|
/* v8 ignore next 40 -- @preserve */
|
|
2900
3360
|
if (this._options.ai) {
|
|
2901
3361
|
const aiModel = await createAIModel(this._options.ai);
|
|
@@ -2966,6 +3426,10 @@ var DoculaBuilder = class {
|
|
|
2966
3426
|
await fs.promises.copyFile(`${siteRelativePath}/logo.svg`, `${this.options.output}/logo.svg`);
|
|
2967
3427
|
this._console.fileCopied("logo.svg");
|
|
2968
3428
|
}
|
|
3429
|
+
if (!hashAssetAndCheckSkip(this._hash, path.join(siteRelativePath, "logo.png"), path.join(this.options.output, "logo.png"), "logo.png", previousAssets, currentAssetHashes)) {
|
|
3430
|
+
await fs.promises.copyFile(path.join(siteRelativePath, "logo.png"), path.join(this.options.output, "logo.png"));
|
|
3431
|
+
this._console.fileCopied("logo.png");
|
|
3432
|
+
}
|
|
2969
3433
|
if (!hashAssetAndCheckSkip(this._hash, `${siteRelativePath}/logo_horizontal.png`, `${this.options.output}/logo_horizontal.png`, "logo_horizontal.png", previousAssets, currentAssetHashes)) {
|
|
2970
3434
|
await fs.promises.copyFile(`${siteRelativePath}/logo_horizontal.png`, `${this.options.output}/logo_horizontal.png`);
|
|
2971
3435
|
this._console.fileCopied("logo_horizontal.png");
|
|
@@ -3003,6 +3467,7 @@ var DoculaBuilder = class {
|
|
|
3003
3467
|
/* v8 ignore next 3 -- @preserve */
|
|
3004
3468
|
if (this.options.enableLlmsTxt) this._console.step("Building LLM files...");
|
|
3005
3469
|
await this.buildLlmsFiles(doculaData);
|
|
3470
|
+
await this.buildSearchIndex(doculaData);
|
|
3006
3471
|
ensureCacheInGitignore(this._options, this._console, this.options.sitePath);
|
|
3007
3472
|
const newManifest = {
|
|
3008
3473
|
version: 1,
|
|
@@ -3033,6 +3498,7 @@ var DoculaBuilder = class {
|
|
|
3033
3498
|
const cwdReadmePath = path.join(cwdDir, "README.md");
|
|
3034
3499
|
if (!fs.existsSync(cwdReadmePath)) return;
|
|
3035
3500
|
let readmeContent = await fs.promises.readFile(cwdReadmePath, "utf8");
|
|
3501
|
+
/* v8 ignore next -- split() always yields at least one element; ?? is defensive -- @preserve */
|
|
3036
3502
|
const firstLine = readmeContent.trimStart().split("\n")[0] ?? "";
|
|
3037
3503
|
if (!/^#\s+/.test(firstLine)) {
|
|
3038
3504
|
const packageJsonPath = path.join(cwdDir, "package.json");
|
|
@@ -3111,6 +3577,9 @@ var DoculaBuilder = class {
|
|
|
3111
3577
|
async buildLlmsFiles(data) {
|
|
3112
3578
|
return buildLlmsFiles(this._options, this._console, data);
|
|
3113
3579
|
}
|
|
3580
|
+
async buildSearchIndex(data) {
|
|
3581
|
+
return buildSearchIndex(this._console, data);
|
|
3582
|
+
}
|
|
3114
3583
|
resolveOpenGraphData(data, pageUrl, pageData) {
|
|
3115
3584
|
return resolveOpenGraphData(data, pageUrl, pageData);
|
|
3116
3585
|
}
|
|
@@ -3352,14 +3821,14 @@ var Docula = class {
|
|
|
3352
3821
|
this._console.printHelp();
|
|
3353
3822
|
return;
|
|
3354
3823
|
}
|
|
3355
|
-
if (consoleProcess.args.sitePath) this.options.sitePath = consoleProcess.args.sitePath;
|
|
3356
|
-
await this.loadConfigFile(this.options.sitePath);
|
|
3357
|
-
if (this._configFileModule.options) this.options.parseOptions(this._configFileModule.options);
|
|
3358
|
-
if (this.options.quiet) this._console.quiet = true;
|
|
3359
3824
|
if (consoleProcess.command === "version") {
|
|
3360
3825
|
this._console.log(this.getVersion());
|
|
3361
3826
|
return;
|
|
3362
3827
|
}
|
|
3828
|
+
if (consoleProcess.args.sitePath) this.options.sitePath = consoleProcess.args.sitePath;
|
|
3829
|
+
await this.loadConfigFile(this.options.sitePath);
|
|
3830
|
+
if (this._configFileModule.options) this.options.parseOptions(this._configFileModule.options);
|
|
3831
|
+
if (this.options.quiet) this._console.quiet = true;
|
|
3363
3832
|
if (this._configFileModule.onPrepare) try {
|
|
3364
3833
|
await this._configFileModule.onPrepare(this.options, this._console);
|
|
3365
3834
|
} catch (error) {
|
|
@@ -3518,29 +3987,37 @@ var Docula = class {
|
|
|
3518
3987
|
*/
|
|
3519
3988
|
async loadConfigFile(sitePath) {
|
|
3520
3989
|
if (!fs.existsSync(sitePath)) return;
|
|
3990
|
+
const jsonConfigFile = `${sitePath}/docula.config.json`;
|
|
3521
3991
|
const tsConfigFile = `${sitePath}/docula.config.ts`;
|
|
3522
3992
|
const mjsConfigFile = `${sitePath}/docula.config.mjs`;
|
|
3993
|
+
/* v8 ignore start -- @preserve */
|
|
3994
|
+
if (isSEA()) {
|
|
3995
|
+
if (fs.existsSync(jsonConfigFile)) {
|
|
3996
|
+
const content = fs.readFileSync(jsonConfigFile, "utf8");
|
|
3997
|
+
this._configFileModule = { options: JSON.parse(content) };
|
|
3998
|
+
return;
|
|
3999
|
+
}
|
|
4000
|
+
if (fs.existsSync(tsConfigFile) || fs.existsSync(mjsConfigFile)) throw new Error("Only docula.config.json is supported when running the standalone binary (Node.js SEA cannot dynamic-import file URLs). Convert your config to JSON, or run docula from Node.js to use .ts/.mjs configs.");
|
|
4001
|
+
return;
|
|
4002
|
+
}
|
|
4003
|
+
/* v8 ignore stop */
|
|
3523
4004
|
/* v8 ignore next -- @preserve */
|
|
3524
4005
|
if (fs.existsSync(tsConfigFile)) {
|
|
3525
4006
|
const absolutePath = path.resolve(tsConfigFile);
|
|
3526
|
-
const
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
this._configFileModule = mod.default ?? mod;
|
|
3530
|
-
} catch {
|
|
3531
|
-
throw new Error("TypeScript config files require Node.js 22.6.0 or later when using the standalone binary. Please upgrade Node.js or use docula.config.mjs instead.");
|
|
3532
|
-
}
|
|
3533
|
-
else {
|
|
3534
|
-
const { createJiti } = await import("jiti");
|
|
3535
|
-
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
3536
|
-
this._configFileModule = await jiti.import(absolutePath);
|
|
3537
|
-
}
|
|
4007
|
+
const { createJiti } = await import("jiti");
|
|
4008
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
4009
|
+
this._configFileModule = await jiti.import(absolutePath);
|
|
3538
4010
|
return;
|
|
3539
4011
|
}
|
|
3540
4012
|
/* v8 ignore next -- @preserve */
|
|
3541
4013
|
if (fs.existsSync(mjsConfigFile)) {
|
|
3542
4014
|
const absolutePath = path.resolve(mjsConfigFile);
|
|
3543
4015
|
this._configFileModule = await import(pathToFileURL(absolutePath).href);
|
|
4016
|
+
return;
|
|
4017
|
+
}
|
|
4018
|
+
if (fs.existsSync(jsonConfigFile)) {
|
|
4019
|
+
const content = fs.readFileSync(jsonConfigFile, "utf8");
|
|
4020
|
+
this._configFileModule = { options: JSON.parse(content) };
|
|
3544
4021
|
}
|
|
3545
4022
|
}
|
|
3546
4023
|
/**
|