docula 2.0.0 → 2.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/README.md +16 -2
- package/dist/docula.d.ts +18 -0
- package/dist/docula.js +538 -35
- package/package.json +17 -15
- package/templates/classic/includes/body-scripts.hbs +1 -1
- package/templates/classic/includes/header.hbs +1 -1
- package/templates/modern/css/styles.css +263 -7
- package/templates/modern/includes/body-scripts.hbs +1 -1
- package/templates/modern/includes/header-bar.hbs +8 -0
- package/templates/modern/includes/header.hbs +1 -1
- package/templates/modern/includes/scripts.hbs +28 -17
- package/templates/modern/includes/search.hbs +46 -0
- package/templates/modern/js/search.js +436 -0
package/dist/docula.js
CHANGED
|
@@ -9,11 +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";
|
|
14
18
|
import sea from "node:sea";
|
|
15
19
|
//#region package.json
|
|
16
|
-
var version = "2.
|
|
20
|
+
var version = "2.2.0";
|
|
17
21
|
var package_default = {
|
|
18
22
|
name: "docula",
|
|
19
23
|
version,
|
|
@@ -28,7 +32,7 @@ var package_default = {
|
|
|
28
32
|
"url": "git+https://github.com/jaredwray/docula.git"
|
|
29
33
|
},
|
|
30
34
|
author: "Jared Wray <me@jaredwray.com>",
|
|
31
|
-
engines: { "node": "^22.
|
|
35
|
+
engines: { "node": "^22.19.0 || >=24.0.0" },
|
|
32
36
|
license: "MIT",
|
|
33
37
|
scripts: {
|
|
34
38
|
"clean": "rimraf ./dist ./coverage ./node_modules ./package-lock.json ./yarn.lock ./pnpm-lock.yaml ./site/dist",
|
|
@@ -70,32 +74,34 @@ var package_default = {
|
|
|
70
74
|
],
|
|
71
75
|
bin: { "docula": "./bin/docula.js" },
|
|
72
76
|
dependencies: {
|
|
73
|
-
"@ai-sdk/anthropic": "^3.0.
|
|
74
|
-
"@ai-sdk/google": "^3.0.
|
|
75
|
-
"@ai-sdk/openai": "^3.0.
|
|
76
|
-
"@cacheable/net": "^2.0.
|
|
77
|
-
"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",
|
|
78
82
|
"colorette": "^2.0.20",
|
|
79
|
-
"ecto": "^4.8.
|
|
80
|
-
"hashery": "^
|
|
83
|
+
"ecto": "^4.8.7",
|
|
84
|
+
"hashery": "^3.0.0",
|
|
85
|
+
"ipaddr.js": "2.4.0",
|
|
81
86
|
"jiti": "^2.7.0",
|
|
82
87
|
"serve-handler": "^6.1.7",
|
|
88
|
+
"undici": "8.4.1",
|
|
83
89
|
"update-notifier": "^7.3.1",
|
|
84
90
|
"writr": "^6.1.2"
|
|
85
91
|
},
|
|
86
92
|
devDependencies: {
|
|
87
|
-
"@biomejs/biome": "^2.4.
|
|
93
|
+
"@biomejs/biome": "^2.4.16",
|
|
88
94
|
"@playwright/test": "^1.60.0",
|
|
89
|
-
"@types/node": "^24.
|
|
95
|
+
"@types/node": "^24.13.2",
|
|
90
96
|
"@types/serve-handler": "^6.1.4",
|
|
91
97
|
"@types/update-notifier": "^6.0.8",
|
|
92
|
-
"@vitest/coverage-v8": "^4.1.
|
|
98
|
+
"@vitest/coverage-v8": "^4.1.8",
|
|
93
99
|
"dotenv": "^17.4.2",
|
|
94
100
|
"rimraf": "^6.1.3",
|
|
95
|
-
"tsdown": "^0.22.
|
|
96
|
-
"tsx": "^4.
|
|
101
|
+
"tsdown": "^0.22.2",
|
|
102
|
+
"tsx": "^4.22.4",
|
|
97
103
|
"typescript": "^6.0.3",
|
|
98
|
-
"vitest": "^4.1.
|
|
104
|
+
"vitest": "^4.1.8"
|
|
99
105
|
},
|
|
100
106
|
files: [
|
|
101
107
|
"dist",
|
|
@@ -304,7 +310,10 @@ function logChangelogMetadata(console, name, metadata, fromCache) {
|
|
|
304
310
|
return;
|
|
305
311
|
}
|
|
306
312
|
console.info(`AI enriched changelog: ${name}`);
|
|
307
|
-
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
|
+
));
|
|
308
317
|
if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
|
|
309
318
|
if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
|
|
310
319
|
}
|
|
@@ -722,6 +731,25 @@ function summarizeMarkdown(markdown, maxLength = 240) {
|
|
|
722
731
|
function isRemoteUrl(url) {
|
|
723
732
|
return /^https?:\/\//i.test(url);
|
|
724
733
|
}
|
|
734
|
+
/**
|
|
735
|
+
* Encode a value for use in a URL query string. Unlike `encodeURIComponent`,
|
|
736
|
+
* this also escapes single quotes, so the result is safe to inject into a
|
|
737
|
+
* single-quoted JavaScript string literal (e.g., inline template scripts)
|
|
738
|
+
* where a raw quote would break the string.
|
|
739
|
+
*/
|
|
740
|
+
function encodeQueryStringValue(value) {
|
|
741
|
+
return encodeURIComponent(value).replace(/'/g, "%27");
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Build the extra query string appended to Google Tag Manager URLs when
|
|
745
|
+
* targeting a GTM environment. A GTM environment is identified by both an
|
|
746
|
+
* auth token and an environment name, so this returns `undefined` unless
|
|
747
|
+
* both are set.
|
|
748
|
+
*/
|
|
749
|
+
function buildGtmEnvironmentParams(auth, env) {
|
|
750
|
+
if (!auth || !env) return;
|
|
751
|
+
return `>m_auth=${encodeQueryStringValue(auth)}>m_preview=${encodeQueryStringValue(env)}>m_cookies_win=x`;
|
|
752
|
+
}
|
|
725
753
|
//#endregion
|
|
726
754
|
//#region src/builder-seo.ts
|
|
727
755
|
const writrOptions$5 = {
|
|
@@ -884,6 +912,294 @@ async function buildRobotsPage(options) {
|
|
|
884
912
|
await (fs.existsSync(`${sitePath}/robots.txt`) ? fs.promises.copyFile(`${sitePath}/robots.txt`, robotsPath) : fs.promises.writeFile(robotsPath, "User-agent: *\nDisallow:"));
|
|
885
913
|
}
|
|
886
914
|
//#endregion
|
|
915
|
+
//#region src/safe-fetch.ts
|
|
916
|
+
const DEFAULT_TIMEOUT_MS = 3e4;
|
|
917
|
+
const DEFAULT_MAX_REDIRECTS = 5;
|
|
918
|
+
const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
919
|
+
const MAX_URL_LENGTH = 8192;
|
|
920
|
+
const BLOCKED_HOSTNAMES = new Set([
|
|
921
|
+
"localhost",
|
|
922
|
+
"ip6-localhost",
|
|
923
|
+
"ip6-loopback",
|
|
924
|
+
"localhost.localdomain"
|
|
925
|
+
]);
|
|
926
|
+
const BLOCKED_IPV4_RANGES = new Set([
|
|
927
|
+
"unspecified",
|
|
928
|
+
"broadcast",
|
|
929
|
+
"multicast",
|
|
930
|
+
"linkLocal",
|
|
931
|
+
"loopback",
|
|
932
|
+
"carrierGradeNat",
|
|
933
|
+
"private",
|
|
934
|
+
"reserved",
|
|
935
|
+
"as112",
|
|
936
|
+
"amt"
|
|
937
|
+
]);
|
|
938
|
+
const BLOCKED_IPV6_RANGES = new Set([
|
|
939
|
+
"unspecified",
|
|
940
|
+
"linkLocal",
|
|
941
|
+
"multicast",
|
|
942
|
+
"loopback",
|
|
943
|
+
"uniqueLocal",
|
|
944
|
+
"discard",
|
|
945
|
+
"rfc6145",
|
|
946
|
+
"rfc6052",
|
|
947
|
+
"teredo",
|
|
948
|
+
"benchmarking",
|
|
949
|
+
"amt",
|
|
950
|
+
"as112v6",
|
|
951
|
+
"reserved"
|
|
952
|
+
]);
|
|
953
|
+
var SsrfBlockedError = class extends Error {
|
|
954
|
+
constructor(message) {
|
|
955
|
+
super(message);
|
|
956
|
+
this.name = "SsrfBlockedError";
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
/**
|
|
960
|
+
* Returns true if `ip` is NOT publicly routable.
|
|
961
|
+
*
|
|
962
|
+
* IPv4-mapped (`::ffff:a.b.c.d`), 6to4 (`2002:hh:hh::/16`), and the deprecated
|
|
963
|
+
* IPv4-compatible (`::a.b.c.d`) IPv6 forms all unwrap to an embedded IPv4
|
|
964
|
+
* address and are re-validated against the IPv4 rules. Writing a private IPv4
|
|
965
|
+
* in any of these IPv6 spellings — including the hex form `::ffff:7f00:1` —
|
|
966
|
+
* therefore does not bypass the IPv4 deny-list.
|
|
967
|
+
*
|
|
968
|
+
* Failure-closed: malformed input is treated as private.
|
|
969
|
+
*/
|
|
970
|
+
function isPrivateIp(ip) {
|
|
971
|
+
if (!ipaddr.isValid(ip)) return true;
|
|
972
|
+
const parsed = ipaddr.parse(ip);
|
|
973
|
+
if (parsed.kind() === "ipv4") return BLOCKED_IPV4_RANGES.has(parsed.range());
|
|
974
|
+
const v6 = parsed;
|
|
975
|
+
const range = v6.range();
|
|
976
|
+
if (range === "ipv4Mapped") return isPrivateIp(v6.toIPv4Address().toString());
|
|
977
|
+
if (range === "6to4") {
|
|
978
|
+
const parts = v6.parts;
|
|
979
|
+
/* v8 ignore stop */
|
|
980
|
+
return isPrivateIp(`${(parts[1] ?? 0) >> 8}.${(parts[1] ?? 0) & 255}.${(parts[2] ?? 0) >> 8}.${(parts[2] ?? 0) & 255}`);
|
|
981
|
+
}
|
|
982
|
+
if (range !== "unicast") return BLOCKED_IPV6_RANGES.has(range);
|
|
983
|
+
const parts = v6.parts;
|
|
984
|
+
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))
|
|
985
|
+
/* v8 ignore stop */
|
|
986
|
+
return isPrivateIp(`${(parts[6] ?? 0) >> 8}.${(parts[6] ?? 0) & 255}.${(parts[7] ?? 0) >> 8}.${(parts[7] ?? 0) & 255}`);
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Validates `url` and, for hostname-based URLs, resolves and validates every
|
|
991
|
+
* returned IP. Throws SsrfBlockedError if the URL is unparseable, uses a
|
|
992
|
+
* non-http(s) scheme, embeds credentials, targets a blocked hostname like
|
|
993
|
+
* `localhost`, or resolves to any non-publicly-routable IP. Returns the
|
|
994
|
+
* parsed URL and the validated addresses so the caller can pin them at
|
|
995
|
+
* connect time and close the DNS-rebinding TOCTOU window.
|
|
996
|
+
*/
|
|
997
|
+
async function resolveAndValidate(url, options = {}) {
|
|
998
|
+
if (url.length > MAX_URL_LENGTH) throw new SsrfBlockedError(`URL exceeds ${MAX_URL_LENGTH} characters (got ${url.length})`);
|
|
999
|
+
let parsed;
|
|
1000
|
+
try {
|
|
1001
|
+
parsed = new URL(url);
|
|
1002
|
+
} catch {
|
|
1003
|
+
throw new SsrfBlockedError(`Invalid URL: ${url}`);
|
|
1004
|
+
}
|
|
1005
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new SsrfBlockedError(`Only http(s) URLs are allowed (got ${parsed.protocol})`);
|
|
1006
|
+
if (parsed.username || parsed.password) throw new SsrfBlockedError("Refusing to fetch URL with embedded credentials");
|
|
1007
|
+
const rawHostname = parsed.hostname;
|
|
1008
|
+
/* v8 ignore next 3 -- @preserve */
|
|
1009
|
+
if (!rawHostname) throw new SsrfBlockedError("URL has no hostname");
|
|
1010
|
+
const hostname = rawHostname.startsWith("[") && rawHostname.endsWith("]") ? rawHostname.slice(1, -1) : rawHostname;
|
|
1011
|
+
const lowerHost = hostname.toLowerCase();
|
|
1012
|
+
if (BLOCKED_HOSTNAMES.has(lowerHost) || lowerHost.endsWith(".localhost") || lowerHost.endsWith(".localhost.localdomain")) throw new SsrfBlockedError(`Refusing to fetch from ${hostname}`);
|
|
1013
|
+
if (net.isIP(hostname)) {
|
|
1014
|
+
if (isPrivateIp(hostname)) throw new SsrfBlockedError(`Refusing to fetch from non-public IP ${hostname}`);
|
|
1015
|
+
return {
|
|
1016
|
+
urlObj: parsed,
|
|
1017
|
+
resolved: [{
|
|
1018
|
+
address: hostname,
|
|
1019
|
+
family: net.isIPv6(hostname) ? 6 : 4
|
|
1020
|
+
}]
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
/* v8 ignore next -- the `?? dns.lookup` default invokes real DNS; only reachable without an injected lookup -- @preserve */
|
|
1024
|
+
const lookupFn = options.lookup ?? promises.lookup;
|
|
1025
|
+
let addresses;
|
|
1026
|
+
try {
|
|
1027
|
+
addresses = await lookupFn(hostname, {
|
|
1028
|
+
all: true,
|
|
1029
|
+
verbatim: true
|
|
1030
|
+
});
|
|
1031
|
+
} catch (error) {
|
|
1032
|
+
throw new SsrfBlockedError(`DNS lookup for ${hostname} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
1033
|
+
}
|
|
1034
|
+
if (addresses.length === 0) throw new SsrfBlockedError(`DNS lookup for ${hostname} returned no addresses`);
|
|
1035
|
+
for (const { address } of addresses) if (isPrivateIp(address)) throw new SsrfBlockedError(`Refusing to fetch from ${hostname} — resolves to non-public IP ${address}`);
|
|
1036
|
+
return {
|
|
1037
|
+
urlObj: parsed,
|
|
1038
|
+
resolved: addresses.map((a) => ({
|
|
1039
|
+
address: a.address,
|
|
1040
|
+
family: a.family === 6 ? 6 : 4
|
|
1041
|
+
}))
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Builds a `net.lookup`-shaped callback that always returns the pre-validated
|
|
1046
|
+
* `resolved` addresses, ignoring the hostname argument. Supports both call
|
|
1047
|
+
* signatures: when `opts.all` is truthy the callback receives the full array;
|
|
1048
|
+
* otherwise (the shape Node uses when `autoSelectFamily` is disabled) the
|
|
1049
|
+
* callback receives a single `address, family` pair.
|
|
1050
|
+
*/
|
|
1051
|
+
function pinnedLookup(resolved) {
|
|
1052
|
+
return (_hostname, opts, callback) => {
|
|
1053
|
+
if (opts.all) {
|
|
1054
|
+
callback(null, resolved);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1057
|
+
const first = resolved[0];
|
|
1058
|
+
/* v8 ignore next 4 -- resolved is non-empty by construction in safeFetch -- @preserve */
|
|
1059
|
+
if (!first) {
|
|
1060
|
+
callback(/* @__PURE__ */ new Error("No pinned address available"));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
callback(null, first.address, first.family);
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* fetch wrapper hardened against SSRF:
|
|
1068
|
+
* - Scheme allow-list (http/https), no embedded credentials, no localhost.
|
|
1069
|
+
* - The resolved IP is pinned to undici's TCP connect via a custom Agent
|
|
1070
|
+
* `lookup`, so fetch does not re-resolve the hostname. This closes the
|
|
1071
|
+
* DNS-rebinding TOCTOU window between validation and connect. TLS SNI
|
|
1072
|
+
* and certificate validation still use the original hostname because
|
|
1073
|
+
* undici's connector passes `host: hostname` to `tls.connect` regardless
|
|
1074
|
+
* of what `lookup` returns; if that internal contract changes in a future
|
|
1075
|
+
* undici release, this wrapper's TLS-hostname guarantee will need to be
|
|
1076
|
+
* re-checked.
|
|
1077
|
+
* - Redirects are followed manually; each target is re-validated and the
|
|
1078
|
+
* new connect is re-pinned. The previous hop's body is cancelled before
|
|
1079
|
+
* moving on so the connection is released instead of being held open.
|
|
1080
|
+
* - `timeoutMs` bounds the total wall-clock budget — the AbortController
|
|
1081
|
+
* survives until the returned body is fully consumed (or errors), so a
|
|
1082
|
+
* slow-drip body cannot bypass the deadline.
|
|
1083
|
+
* - `maxBodyBytes` caps the response body up-front via `content-length`
|
|
1084
|
+
* (when it parses cleanly as a decimal integer) and during streaming.
|
|
1085
|
+
* - URLs over `MAX_URL_LENGTH` are rejected before parsing.
|
|
1086
|
+
* - Per-hop Agents are `destroy()`ed (not gracefully `close()`d) on error
|
|
1087
|
+
* or after the body is consumed, so a malicious server cannot leak
|
|
1088
|
+
* sockets by holding a redirected body open.
|
|
1089
|
+
*/
|
|
1090
|
+
async function safeFetch(url, options = {}) {
|
|
1091
|
+
const maxRedirects = options.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
1092
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
1093
|
+
const maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
|
|
1094
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
1095
|
+
const lookup = options.lookup;
|
|
1096
|
+
const totalController = new AbortController();
|
|
1097
|
+
const totalTimer = setTimeout(() => totalController.abort(), timeoutMs);
|
|
1098
|
+
const dispatchers = [];
|
|
1099
|
+
let cleaned = false;
|
|
1100
|
+
const cleanup = () => {
|
|
1101
|
+
if (cleaned) return;
|
|
1102
|
+
cleaned = true;
|
|
1103
|
+
clearTimeout(totalTimer);
|
|
1104
|
+
for (const d of dispatchers)
|
|
1105
|
+
/* v8 ignore next -- the .catch arrow only runs if undici Agent.destroy() rejects, which it does not in practice -- @preserve */
|
|
1106
|
+
d.destroy().catch(() => {});
|
|
1107
|
+
};
|
|
1108
|
+
let currentUrl = url;
|
|
1109
|
+
try {
|
|
1110
|
+
for (let hop = 0; hop <= maxRedirects; hop++) {
|
|
1111
|
+
const { resolved } = await resolveAndValidate(currentUrl, { lookup });
|
|
1112
|
+
const dispatcher = new Agent({ connect: { lookup: pinnedLookup(resolved) } });
|
|
1113
|
+
dispatchers.push(dispatcher);
|
|
1114
|
+
const response = await fetchImpl(currentUrl, {
|
|
1115
|
+
dispatcher,
|
|
1116
|
+
redirect: "manual",
|
|
1117
|
+
signal: totalController.signal
|
|
1118
|
+
});
|
|
1119
|
+
if (response.status >= 300 && response.status < 400) {
|
|
1120
|
+
const location = response.headers.get("location");
|
|
1121
|
+
if (location) {
|
|
1122
|
+
await response.body?.cancel().catch(() => {});
|
|
1123
|
+
/* v8 ignore next -- the .catch arrow only runs if undici Agent.destroy() rejects, which it does not in practice -- @preserve */
|
|
1124
|
+
dispatcher.destroy().catch(() => {});
|
|
1125
|
+
dispatchers.pop();
|
|
1126
|
+
let nextUrl;
|
|
1127
|
+
try {
|
|
1128
|
+
nextUrl = new URL(location, currentUrl).toString();
|
|
1129
|
+
} catch {
|
|
1130
|
+
throw new SsrfBlockedError(`Invalid redirect Location header (${location}) from ${currentUrl}`);
|
|
1131
|
+
}
|
|
1132
|
+
currentUrl = nextUrl;
|
|
1133
|
+
continue;
|
|
1134
|
+
}
|
|
1135
|
+
return capResponseBody(response, maxBodyBytes, cleanup);
|
|
1136
|
+
}
|
|
1137
|
+
return capResponseBody(response, maxBodyBytes, cleanup);
|
|
1138
|
+
}
|
|
1139
|
+
throw new SsrfBlockedError(`Too many redirects (>${maxRedirects}) starting at ${url}`);
|
|
1140
|
+
} catch (error) {
|
|
1141
|
+
cleanup();
|
|
1142
|
+
throw error;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
function capResponseBody(response, maxBytes, onDone) {
|
|
1146
|
+
const contentLength = response.headers.get("content-length");
|
|
1147
|
+
if (contentLength && /^\d+$/.test(contentLength)) {
|
|
1148
|
+
const n = Number.parseInt(contentLength, 10);
|
|
1149
|
+
if (n > maxBytes) {
|
|
1150
|
+
onDone();
|
|
1151
|
+
throw new SsrfBlockedError(`Response body exceeds ${maxBytes} bytes (content-length: ${n})`);
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
if (!response.body) {
|
|
1155
|
+
onDone();
|
|
1156
|
+
return response;
|
|
1157
|
+
}
|
|
1158
|
+
const reader = response.body.getReader();
|
|
1159
|
+
let received = 0;
|
|
1160
|
+
let finalized = false;
|
|
1161
|
+
const finalize = () => {
|
|
1162
|
+
/* v8 ignore next -- double-call guard; each finalize caller settles the stream, so finalize cannot deterministically run twice -- @preserve */
|
|
1163
|
+
if (finalized) return;
|
|
1164
|
+
finalized = true;
|
|
1165
|
+
onDone();
|
|
1166
|
+
};
|
|
1167
|
+
const cappedStream = new ReadableStream({
|
|
1168
|
+
async pull(controller) {
|
|
1169
|
+
try {
|
|
1170
|
+
const { value, done } = await reader.read();
|
|
1171
|
+
if (done) {
|
|
1172
|
+
controller.close();
|
|
1173
|
+
finalize();
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
received += value.byteLength;
|
|
1177
|
+
if (received > maxBytes) {
|
|
1178
|
+
controller.error(new SsrfBlockedError(`Response body exceeds ${maxBytes} bytes (streamed)`));
|
|
1179
|
+
reader.cancel().catch(() => {});
|
|
1180
|
+
finalize();
|
|
1181
|
+
return;
|
|
1182
|
+
}
|
|
1183
|
+
controller.enqueue(value);
|
|
1184
|
+
} catch (error) {
|
|
1185
|
+
controller.error(error);
|
|
1186
|
+
finalize();
|
|
1187
|
+
}
|
|
1188
|
+
/* v8 ignore stop */
|
|
1189
|
+
},
|
|
1190
|
+
/* v8 ignore next 4 -- only invoked when the consumer cancels the body stream -- @preserve */
|
|
1191
|
+
cancel() {
|
|
1192
|
+
reader.cancel().catch(() => {});
|
|
1193
|
+
finalize();
|
|
1194
|
+
}
|
|
1195
|
+
});
|
|
1196
|
+
return new Response(cappedStream, {
|
|
1197
|
+
status: response.status,
|
|
1198
|
+
statusText: response.statusText,
|
|
1199
|
+
headers: response.headers
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
//#endregion
|
|
887
1203
|
//#region src/builder-api.ts
|
|
888
1204
|
const writrOptions$4 = {
|
|
889
1205
|
throwOnEmitError: false,
|
|
@@ -913,8 +1229,10 @@ async function getSafeSiteOverrideFileContent(sitePath, fileName) {
|
|
|
913
1229
|
realSitePath = await fs.promises.realpath(resolvedSitePath);
|
|
914
1230
|
realCandidatePath = await fs.promises.realpath(candidatePath);
|
|
915
1231
|
} catch {
|
|
1232
|
+
/* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
|
|
916
1233
|
return;
|
|
917
1234
|
}
|
|
1235
|
+
/* 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 */
|
|
918
1236
|
if (!isPathWithinBasePath(realCandidatePath, realSitePath)) return;
|
|
919
1237
|
return fs.promises.readFile(realCandidatePath, "utf8");
|
|
920
1238
|
}
|
|
@@ -950,6 +1268,7 @@ async function getSafeLocalOpenApiSpecForSpec(data, specUrl) {
|
|
|
950
1268
|
realSitePath = await fs.promises.realpath(resolvedSitePath);
|
|
951
1269
|
realLocalPath = await fs.promises.realpath(resolvedLocalPath);
|
|
952
1270
|
} catch {
|
|
1271
|
+
/* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
|
|
953
1272
|
return;
|
|
954
1273
|
}
|
|
955
1274
|
if (!isPathWithinBasePath(realLocalPath, realSitePath)) return;
|
|
@@ -969,7 +1288,7 @@ async function parseAndRenderSpec(data, specUrl) {
|
|
|
969
1288
|
const localSpec = await getSafeLocalOpenApiSpecForSpec(data, specUrl);
|
|
970
1289
|
if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
|
|
971
1290
|
else if (isRemoteUrl(specUrl)) try {
|
|
972
|
-
apiSpec = parseOpenApiSpec(await (await
|
|
1291
|
+
apiSpec = parseOpenApiSpec(await (await safeFetch(specUrl)).text());
|
|
973
1292
|
} catch {}
|
|
974
1293
|
/* v8 ignore next -- @preserve */
|
|
975
1294
|
if (apiSpec) {
|
|
@@ -1024,6 +1343,7 @@ async function buildAllApiPages(ecto, data) {
|
|
|
1024
1343
|
async function renderApiContent(ecto, data) {
|
|
1025
1344
|
const firstSpecUrl = data.openApiSpecs?.[0]?.url;
|
|
1026
1345
|
if (!firstSpecUrl || !data.templates?.api) throw new Error("No API template or openApiUrl found");
|
|
1346
|
+
/* 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 */
|
|
1027
1347
|
if (data.openApiSpecs && data.openApiSpecs.length > 0) return renderCombinedApiContent(ecto, data);
|
|
1028
1348
|
/* v8 ignore start -- @preserve */
|
|
1029
1349
|
const swaggerSource = `${data.sitePath}/api/swagger.json`;
|
|
@@ -1034,7 +1354,7 @@ async function renderApiContent(ecto, data) {
|
|
|
1034
1354
|
const localSpec = await getSafeLocalOpenApiSpec(data);
|
|
1035
1355
|
if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
|
|
1036
1356
|
else if (firstSpecUrl && isRemoteUrl(firstSpecUrl)) try {
|
|
1037
|
-
apiSpec = parseOpenApiSpec(await (await
|
|
1357
|
+
apiSpec = parseOpenApiSpec(await (await safeFetch(firstSpecUrl)).text());
|
|
1038
1358
|
} catch {}
|
|
1039
1359
|
if (apiSpec) {
|
|
1040
1360
|
apiSpec.info.description = new Writr$1(apiSpec.info.description, writrOptions$4).renderSync();
|
|
@@ -1120,6 +1440,7 @@ function hashOptions(hash, options) {
|
|
|
1120
1440
|
template: options.template,
|
|
1121
1441
|
templatePath: options.templatePath,
|
|
1122
1442
|
enableLlmsTxt: options.enableLlmsTxt,
|
|
1443
|
+
enableSearch: options.enableSearch,
|
|
1123
1444
|
changelogPerPage: options.changelogPerPage,
|
|
1124
1445
|
enableReleaseChangelog: options.enableReleaseChangelog,
|
|
1125
1446
|
sections: options.sections,
|
|
@@ -1135,7 +1456,9 @@ function hashOptions(hash, options) {
|
|
|
1135
1456
|
apiPath: options.apiPath,
|
|
1136
1457
|
changelogPath: options.changelogPath,
|
|
1137
1458
|
ai: options.ai,
|
|
1138
|
-
googleTagManager: options.googleTagManager
|
|
1459
|
+
googleTagManager: options.googleTagManager,
|
|
1460
|
+
googleTagManagerAuth: options.googleTagManagerAuth,
|
|
1461
|
+
googleTagManagerEnv: options.googleTagManagerEnv
|
|
1139
1462
|
};
|
|
1140
1463
|
const optionsHash = hash.toHashSync(JSON.stringify(relevant));
|
|
1141
1464
|
const configHash = hashConfigFile(hash, options.sitePath);
|
|
@@ -2112,6 +2435,153 @@ async function generateLlmsFullContent(data) {
|
|
|
2112
2435
|
return lines.join("\n");
|
|
2113
2436
|
}
|
|
2114
2437
|
//#endregion
|
|
2438
|
+
//#region src/builder-search.ts
|
|
2439
|
+
/**
|
|
2440
|
+
* Name of the JSON search index written to the output root. The client-side
|
|
2441
|
+
* search script fetches this file (relative to {@link DoculaData.baseUrl}) to
|
|
2442
|
+
* power the search modal.
|
|
2443
|
+
*/
|
|
2444
|
+
const SEARCH_INDEX_FILENAME = "search-index.json";
|
|
2445
|
+
const NAMED_ENTITIES = {
|
|
2446
|
+
amp: "&",
|
|
2447
|
+
lt: "<",
|
|
2448
|
+
gt: ">",
|
|
2449
|
+
quot: "\"",
|
|
2450
|
+
apos: "'",
|
|
2451
|
+
nbsp: " ",
|
|
2452
|
+
copy: "©",
|
|
2453
|
+
reg: "®",
|
|
2454
|
+
trade: "™",
|
|
2455
|
+
hellip: "…",
|
|
2456
|
+
mdash: "—",
|
|
2457
|
+
ndash: "–",
|
|
2458
|
+
lsquo: "‘",
|
|
2459
|
+
rsquo: "’",
|
|
2460
|
+
ldquo: "“",
|
|
2461
|
+
rdquo: "”"
|
|
2462
|
+
};
|
|
2463
|
+
const ENTITY_REGEX = /&(#[xX]?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/g;
|
|
2464
|
+
const HEADING_REGEX = /<h([1-6])\b[^>]*\bid="([^"]+)"[^>]*>([\s\S]*?)<\/h\1>/gi;
|
|
2465
|
+
/**
|
|
2466
|
+
* Decode the small set of HTML entities that can appear in rendered markdown
|
|
2467
|
+
* (named, decimal, and hexadecimal). Unknown named entities are left intact.
|
|
2468
|
+
*/
|
|
2469
|
+
function decodeEntities(text) {
|
|
2470
|
+
return text.replace(ENTITY_REGEX, (match, entity) => {
|
|
2471
|
+
if (entity[0] === "#") {
|
|
2472
|
+
const codePoint = entity[1] === "x" || entity[1] === "X" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
2473
|
+
if (Number.isNaN(codePoint)) return match;
|
|
2474
|
+
try {
|
|
2475
|
+
return String.fromCodePoint(codePoint);
|
|
2476
|
+
} catch {
|
|
2477
|
+
return match;
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return NAMED_ENTITIES[entity.toLowerCase()] ?? match;
|
|
2481
|
+
});
|
|
2482
|
+
}
|
|
2483
|
+
/**
|
|
2484
|
+
* Convert an HTML fragment into collapsed, decoded plain text. Script and
|
|
2485
|
+
* style blocks are dropped entirely before tags are stripped.
|
|
2486
|
+
*/
|
|
2487
|
+
function stripHtml(html) {
|
|
2488
|
+
if (!html) return "";
|
|
2489
|
+
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();
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Remove a trailing `index.html` so urls point at the clean directory path
|
|
2493
|
+
* that the static site actually serves.
|
|
2494
|
+
*/
|
|
2495
|
+
function stripIndexHtml(urlPath) {
|
|
2496
|
+
return urlPath.replace(/index\.html$/, "");
|
|
2497
|
+
}
|
|
2498
|
+
function isTableOfContents(anchor, title) {
|
|
2499
|
+
return anchor === "table-of-contents" || title.trim().toLowerCase() === "table of contents";
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* Split a rendered HTML page into search records: one for the page intro
|
|
2503
|
+
* (content before the first heading) plus one per heading section. The
|
|
2504
|
+
* injected "Table of Contents" section is skipped.
|
|
2505
|
+
*/
|
|
2506
|
+
function extractSections(html, pageTitle, pageUrl) {
|
|
2507
|
+
const source = html ?? "";
|
|
2508
|
+
const records = [];
|
|
2509
|
+
const headings = [];
|
|
2510
|
+
HEADING_REGEX.lastIndex = 0;
|
|
2511
|
+
let match = HEADING_REGEX.exec(source);
|
|
2512
|
+
while (match !== null) {
|
|
2513
|
+
headings.push({
|
|
2514
|
+
level: Number.parseInt(match[1], 10),
|
|
2515
|
+
anchor: match[2],
|
|
2516
|
+
title: stripHtml(match[3]),
|
|
2517
|
+
headingStart: match.index,
|
|
2518
|
+
contentStart: match.index + match[0].length
|
|
2519
|
+
});
|
|
2520
|
+
match = HEADING_REGEX.exec(source);
|
|
2521
|
+
}
|
|
2522
|
+
const introEnd = headings.length > 0 ? headings[0].headingStart : source.length;
|
|
2523
|
+
records.push({
|
|
2524
|
+
id: pageUrl,
|
|
2525
|
+
title: pageTitle,
|
|
2526
|
+
titles: [],
|
|
2527
|
+
text: stripHtml(source.slice(0, introEnd)),
|
|
2528
|
+
url: pageUrl
|
|
2529
|
+
});
|
|
2530
|
+
const stack = [];
|
|
2531
|
+
for (let index = 0; index < headings.length; index++) {
|
|
2532
|
+
const heading = headings[index];
|
|
2533
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= heading.level) stack.pop();
|
|
2534
|
+
if (isTableOfContents(heading.anchor, heading.title)) continue;
|
|
2535
|
+
const next = headings[index + 1];
|
|
2536
|
+
const sectionEnd = next ? next.headingStart : source.length;
|
|
2537
|
+
const url = `${pageUrl}#${heading.anchor}`;
|
|
2538
|
+
records.push({
|
|
2539
|
+
id: url,
|
|
2540
|
+
title: heading.title,
|
|
2541
|
+
titles: [pageTitle, ...stack.map((item) => item.title)],
|
|
2542
|
+
text: stripHtml(source.slice(heading.contentStart, sectionEnd)),
|
|
2543
|
+
url
|
|
2544
|
+
});
|
|
2545
|
+
stack.push({
|
|
2546
|
+
level: heading.level,
|
|
2547
|
+
title: heading.title
|
|
2548
|
+
});
|
|
2549
|
+
}
|
|
2550
|
+
return records;
|
|
2551
|
+
}
|
|
2552
|
+
/**
|
|
2553
|
+
* Build the full list of search records for a site from its documents and
|
|
2554
|
+
* (published) changelog entries.
|
|
2555
|
+
*/
|
|
2556
|
+
function generateSearchRecords(data) {
|
|
2557
|
+
const records = [];
|
|
2558
|
+
for (const document of data.documents ?? []) {
|
|
2559
|
+
const pageTitle = document.navTitle || document.title || "Untitled";
|
|
2560
|
+
const url = `${data.baseUrl}${stripIndexHtml(document.urlPath)}`;
|
|
2561
|
+
records.push(...extractSections(document.generatedHtml, pageTitle, url));
|
|
2562
|
+
}
|
|
2563
|
+
for (const entry of data.changelogEntries ?? []) {
|
|
2564
|
+
if (entry.draft) continue;
|
|
2565
|
+
const pageTitle = entry.title || "Untitled";
|
|
2566
|
+
const url = stripIndexHtml(entry.urlPath);
|
|
2567
|
+
records.push(...extractSections(entry.generatedHtml, pageTitle, url));
|
|
2568
|
+
}
|
|
2569
|
+
return records;
|
|
2570
|
+
}
|
|
2571
|
+
/**
|
|
2572
|
+
* Generate the search index JSON for the build when search is enabled. The
|
|
2573
|
+
* file is written to the output root and consumed by the client-side search.
|
|
2574
|
+
*/
|
|
2575
|
+
async function buildSearchIndex(console, data) {
|
|
2576
|
+
if (!data.enableSearch) return;
|
|
2577
|
+
console.step("Building search index...");
|
|
2578
|
+
await fs.promises.mkdir(data.output, { recursive: true });
|
|
2579
|
+
const records = generateSearchRecords(data);
|
|
2580
|
+
const outputPath = `${data.output}/${SEARCH_INDEX_FILENAME}`;
|
|
2581
|
+
await fs.promises.writeFile(outputPath, JSON.stringify({ records }), "utf8");
|
|
2582
|
+
console.fileBuilt(SEARCH_INDEX_FILENAME);
|
|
2583
|
+
}
|
|
2584
|
+
//#endregion
|
|
2115
2585
|
//#region src/console.ts
|
|
2116
2586
|
const ansiRegex = /\u001B\[[0-9;]*m/g;
|
|
2117
2587
|
var DoculaConsole = class {
|
|
@@ -2513,6 +2983,11 @@ var DoculaOptions = class {
|
|
|
2513
2983
|
*/
|
|
2514
2984
|
enableLlmsTxt = true;
|
|
2515
2985
|
/**
|
|
2986
|
+
* When true, generates a search index (search-index.json) and renders the
|
|
2987
|
+
* search UI in templates that support it. Set to false to disable search.
|
|
2988
|
+
*/
|
|
2989
|
+
enableSearch = true;
|
|
2990
|
+
/**
|
|
2516
2991
|
* Override the default theme toggle. By default the site follows the system
|
|
2517
2992
|
* preference. Set to "light" or "dark" to use that theme when no user
|
|
2518
2993
|
* preference is stored in localStorage.
|
|
@@ -2587,6 +3062,16 @@ var DoculaOptions = class {
|
|
|
2587
3062
|
*/
|
|
2588
3063
|
googleTagManager;
|
|
2589
3064
|
/**
|
|
3065
|
+
* Google Tag Manager environment auth token (gtm_auth). Only applies to
|
|
3066
|
+
* GTM container IDs; requires `googleTagManagerEnv` to also be set.
|
|
3067
|
+
*/
|
|
3068
|
+
googleTagManagerAuth;
|
|
3069
|
+
/**
|
|
3070
|
+
* Google Tag Manager environment name (gtm_preview), e.g., "env-3". Only
|
|
3071
|
+
* applies to GTM container IDs; requires `googleTagManagerAuth` to also be set.
|
|
3072
|
+
*/
|
|
3073
|
+
googleTagManagerEnv;
|
|
3074
|
+
/**
|
|
2590
3075
|
* AI-powered metadata enrichment configuration. When set, uses AI to fill
|
|
2591
3076
|
* missing OpenGraph and HTML meta tag fields during the build.
|
|
2592
3077
|
* Requires provider name and API key. Omit to disable AI enrichment.
|
|
@@ -2671,11 +3156,14 @@ var DoculaOptions = class {
|
|
|
2671
3156
|
if (options.enableReleaseChangelog !== void 0 && typeof options.enableReleaseChangelog === "boolean") this.enableReleaseChangelog = options.enableReleaseChangelog;
|
|
2672
3157
|
if (options.changelogPerPage !== void 0 && typeof options.changelogPerPage === "number" && options.changelogPerPage > 0) this.changelogPerPage = options.changelogPerPage;
|
|
2673
3158
|
if (options.enableLlmsTxt !== void 0 && typeof options.enableLlmsTxt === "boolean") this.enableLlmsTxt = options.enableLlmsTxt;
|
|
3159
|
+
if (options.enableSearch !== void 0 && typeof options.enableSearch === "boolean") this.enableSearch = options.enableSearch;
|
|
2674
3160
|
if (options.themeMode !== void 0 && (options.themeMode === "light" || options.themeMode === "dark")) this.themeMode = options.themeMode;
|
|
2675
3161
|
if (options.autoUpdateIgnores !== void 0 && typeof options.autoUpdateIgnores === "boolean") this.autoUpdateIgnores = options.autoUpdateIgnores;
|
|
2676
3162
|
if (options.autoReadme !== void 0 && typeof options.autoReadme === "boolean") this.autoReadme = options.autoReadme;
|
|
2677
3163
|
if (options.quiet !== void 0 && typeof options.quiet === "boolean") this.quiet = options.quiet;
|
|
2678
3164
|
if (options.googleTagManager !== void 0 && typeof options.googleTagManager === "string" && /^G[A-Z]*-[A-Z0-9]+$/i.test(options.googleTagManager)) this.googleTagManager = options.googleTagManager;
|
|
3165
|
+
if (options.googleTagManagerAuth !== void 0 && typeof options.googleTagManagerAuth === "string" && options.googleTagManagerAuth.length > 0) this.googleTagManagerAuth = options.googleTagManagerAuth;
|
|
3166
|
+
if (options.googleTagManagerEnv !== void 0 && typeof options.googleTagManagerEnv === "string" && options.googleTagManagerEnv.length > 0) this.googleTagManagerEnv = options.googleTagManagerEnv;
|
|
2679
3167
|
if (options.ai && typeof options.ai === "object" && typeof options.ai.provider === "string" && typeof options.ai.apiKey === "string") this.ai = options.ai;
|
|
2680
3168
|
if (options.cache && typeof options.cache === "object" && options.cache.github !== null && typeof options.cache.github === "object" && typeof options.cache.github.ttl === "number") this.cache = options.cache;
|
|
2681
3169
|
if (options.homeUrl !== void 0 && typeof options.homeUrl === "string") this.homeUrl = options.homeUrl === "/" ? "/" : trimTrailingSlashes(options.homeUrl);
|
|
@@ -2817,6 +3305,7 @@ var DoculaBuilder = class {
|
|
|
2817
3305
|
headerLinks: this.options.headerLinks,
|
|
2818
3306
|
googleTagManager: this.options.googleTagManager,
|
|
2819
3307
|
isGtag: this.options.googleTagManager?.startsWith("G-") ?? false,
|
|
3308
|
+
googleTagManagerParams: buildGtmEnvironmentParams(this.options.googleTagManagerAuth, this.options.googleTagManagerEnv),
|
|
2820
3309
|
enableLlmsTxt: this.options.enableLlmsTxt,
|
|
2821
3310
|
homeUrl: this.options.homeUrl,
|
|
2822
3311
|
baseUrl: this.options.baseUrl,
|
|
@@ -2900,6 +3389,7 @@ var DoculaBuilder = class {
|
|
|
2900
3389
|
});
|
|
2901
3390
|
doculaData.changelogEntries = allChangelogEntries;
|
|
2902
3391
|
doculaData.hasChangelog = allChangelogEntries.length > 0 && hasChangelogTemplate;
|
|
3392
|
+
doculaData.enableSearch = Boolean(this.options.enableSearch && (doculaData.hasDocuments || doculaData.hasChangelog));
|
|
2903
3393
|
/* v8 ignore next 40 -- @preserve */
|
|
2904
3394
|
if (this._options.ai) {
|
|
2905
3395
|
const aiModel = await createAIModel(this._options.ai);
|
|
@@ -3011,6 +3501,7 @@ var DoculaBuilder = class {
|
|
|
3011
3501
|
/* v8 ignore next 3 -- @preserve */
|
|
3012
3502
|
if (this.options.enableLlmsTxt) this._console.step("Building LLM files...");
|
|
3013
3503
|
await this.buildLlmsFiles(doculaData);
|
|
3504
|
+
await this.buildSearchIndex(doculaData);
|
|
3014
3505
|
ensureCacheInGitignore(this._options, this._console, this.options.sitePath);
|
|
3015
3506
|
const newManifest = {
|
|
3016
3507
|
version: 1,
|
|
@@ -3041,6 +3532,7 @@ var DoculaBuilder = class {
|
|
|
3041
3532
|
const cwdReadmePath = path.join(cwdDir, "README.md");
|
|
3042
3533
|
if (!fs.existsSync(cwdReadmePath)) return;
|
|
3043
3534
|
let readmeContent = await fs.promises.readFile(cwdReadmePath, "utf8");
|
|
3535
|
+
/* v8 ignore next -- split() always yields at least one element; ?? is defensive -- @preserve */
|
|
3044
3536
|
const firstLine = readmeContent.trimStart().split("\n")[0] ?? "";
|
|
3045
3537
|
if (!/^#\s+/.test(firstLine)) {
|
|
3046
3538
|
const packageJsonPath = path.join(cwdDir, "package.json");
|
|
@@ -3119,6 +3611,9 @@ var DoculaBuilder = class {
|
|
|
3119
3611
|
async buildLlmsFiles(data) {
|
|
3120
3612
|
return buildLlmsFiles(this._options, this._console, data);
|
|
3121
3613
|
}
|
|
3614
|
+
async buildSearchIndex(data) {
|
|
3615
|
+
return buildSearchIndex(this._console, data);
|
|
3616
|
+
}
|
|
3122
3617
|
resolveOpenGraphData(data, pageUrl, pageData) {
|
|
3123
3618
|
return resolveOpenGraphData(data, pageUrl, pageData);
|
|
3124
3619
|
}
|
|
@@ -3360,14 +3855,14 @@ var Docula = class {
|
|
|
3360
3855
|
this._console.printHelp();
|
|
3361
3856
|
return;
|
|
3362
3857
|
}
|
|
3363
|
-
if (consoleProcess.args.sitePath) this.options.sitePath = consoleProcess.args.sitePath;
|
|
3364
|
-
await this.loadConfigFile(this.options.sitePath);
|
|
3365
|
-
if (this._configFileModule.options) this.options.parseOptions(this._configFileModule.options);
|
|
3366
|
-
if (this.options.quiet) this._console.quiet = true;
|
|
3367
3858
|
if (consoleProcess.command === "version") {
|
|
3368
3859
|
this._console.log(this.getVersion());
|
|
3369
3860
|
return;
|
|
3370
3861
|
}
|
|
3862
|
+
if (consoleProcess.args.sitePath) this.options.sitePath = consoleProcess.args.sitePath;
|
|
3863
|
+
await this.loadConfigFile(this.options.sitePath);
|
|
3864
|
+
if (this._configFileModule.options) this.options.parseOptions(this._configFileModule.options);
|
|
3865
|
+
if (this.options.quiet) this._console.quiet = true;
|
|
3371
3866
|
if (this._configFileModule.onPrepare) try {
|
|
3372
3867
|
await this._configFileModule.onPrepare(this.options, this._console);
|
|
3373
3868
|
} catch (error) {
|
|
@@ -3526,29 +4021,37 @@ var Docula = class {
|
|
|
3526
4021
|
*/
|
|
3527
4022
|
async loadConfigFile(sitePath) {
|
|
3528
4023
|
if (!fs.existsSync(sitePath)) return;
|
|
4024
|
+
const jsonConfigFile = `${sitePath}/docula.config.json`;
|
|
3529
4025
|
const tsConfigFile = `${sitePath}/docula.config.ts`;
|
|
3530
4026
|
const mjsConfigFile = `${sitePath}/docula.config.mjs`;
|
|
4027
|
+
/* v8 ignore start -- @preserve */
|
|
4028
|
+
if (isSEA()) {
|
|
4029
|
+
if (fs.existsSync(jsonConfigFile)) {
|
|
4030
|
+
const content = fs.readFileSync(jsonConfigFile, "utf8");
|
|
4031
|
+
this._configFileModule = { options: JSON.parse(content) };
|
|
4032
|
+
return;
|
|
4033
|
+
}
|
|
4034
|
+
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.");
|
|
4035
|
+
return;
|
|
4036
|
+
}
|
|
4037
|
+
/* v8 ignore stop */
|
|
3531
4038
|
/* v8 ignore next -- @preserve */
|
|
3532
4039
|
if (fs.existsSync(tsConfigFile)) {
|
|
3533
4040
|
const absolutePath = path.resolve(tsConfigFile);
|
|
3534
|
-
const
|
|
3535
|
-
|
|
3536
|
-
|
|
3537
|
-
this._configFileModule = mod.default ?? mod;
|
|
3538
|
-
} catch {
|
|
3539
|
-
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.");
|
|
3540
|
-
}
|
|
3541
|
-
else {
|
|
3542
|
-
const { createJiti } = await import("jiti");
|
|
3543
|
-
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
3544
|
-
this._configFileModule = await jiti.import(absolutePath);
|
|
3545
|
-
}
|
|
4041
|
+
const { createJiti } = await import("jiti");
|
|
4042
|
+
const jiti = createJiti(import.meta.url, { interopDefault: true });
|
|
4043
|
+
this._configFileModule = await jiti.import(absolutePath);
|
|
3546
4044
|
return;
|
|
3547
4045
|
}
|
|
3548
4046
|
/* v8 ignore next -- @preserve */
|
|
3549
4047
|
if (fs.existsSync(mjsConfigFile)) {
|
|
3550
4048
|
const absolutePath = path.resolve(mjsConfigFile);
|
|
3551
4049
|
this._configFileModule = await import(pathToFileURL(absolutePath).href);
|
|
4050
|
+
return;
|
|
4051
|
+
}
|
|
4052
|
+
if (fs.existsSync(jsonConfigFile)) {
|
|
4053
|
+
const content = fs.readFileSync(jsonConfigFile, "utf8");
|
|
4054
|
+
this._configFileModule = { options: JSON.parse(content) };
|
|
3552
4055
|
}
|
|
3553
4056
|
}
|
|
3554
4057
|
/**
|