docula 2.0.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/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.0.0";
20
+ var version = "2.1.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.18.0 || >=24.0.0" },
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.77",
74
- "@ai-sdk/google": "^3.0.72",
75
- "@ai-sdk/openai": "^3.0.63",
76
- "@cacheable/net": "^2.0.7",
77
- "ai": "^6.0.178",
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.5",
80
- "hashery": "^2.0.0",
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.15",
93
+ "@biomejs/biome": "^2.4.16",
88
94
  "@playwright/test": "^1.60.0",
89
- "@types/node": "^24.12.4",
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.6",
98
+ "@vitest/coverage-v8": "^4.1.8",
93
99
  "dotenv": "^17.4.2",
94
100
  "rimraf": "^6.1.3",
95
- "tsdown": "^0.22.0",
96
- "tsx": "^4.21.0",
101
+ "tsdown": "^0.22.2",
102
+ "tsx": "^4.22.4",
97
103
  "typescript": "^6.0.3",
98
- "vitest": "^4.1.6"
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(` preview: ${truncate(metadata.preview || metadata.summary || "")}`));
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
  }
@@ -884,6 +893,294 @@ async function buildRobotsPage(options) {
884
893
  await (fs.existsSync(`${sitePath}/robots.txt`) ? fs.promises.copyFile(`${sitePath}/robots.txt`, robotsPath) : fs.promises.writeFile(robotsPath, "User-agent: *\nDisallow:"));
885
894
  }
886
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
887
1184
  //#region src/builder-api.ts
888
1185
  const writrOptions$4 = {
889
1186
  throwOnEmitError: false,
@@ -913,8 +1210,10 @@ async function getSafeSiteOverrideFileContent(sitePath, fileName) {
913
1210
  realSitePath = await fs.promises.realpath(resolvedSitePath);
914
1211
  realCandidatePath = await fs.promises.realpath(candidatePath);
915
1212
  } catch {
1213
+ /* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
916
1214
  return;
917
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 */
918
1217
  if (!isPathWithinBasePath(realCandidatePath, realSitePath)) return;
919
1218
  return fs.promises.readFile(realCandidatePath, "utf8");
920
1219
  }
@@ -950,6 +1249,7 @@ async function getSafeLocalOpenApiSpecForSpec(data, specUrl) {
950
1249
  realSitePath = await fs.promises.realpath(resolvedSitePath);
951
1250
  realLocalPath = await fs.promises.realpath(resolvedLocalPath);
952
1251
  } catch {
1252
+ /* v8 ignore next -- realpath only throws on ENOENT/race after a successful lstat; defensive -- @preserve */
953
1253
  return;
954
1254
  }
955
1255
  if (!isPathWithinBasePath(realLocalPath, realSitePath)) return;
@@ -969,7 +1269,7 @@ async function parseAndRenderSpec(data, specUrl) {
969
1269
  const localSpec = await getSafeLocalOpenApiSpecForSpec(data, specUrl);
970
1270
  if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
971
1271
  else if (isRemoteUrl(specUrl)) try {
972
- apiSpec = parseOpenApiSpec(await (await fetch(specUrl)).text());
1272
+ apiSpec = parseOpenApiSpec(await (await safeFetch(specUrl)).text());
973
1273
  } catch {}
974
1274
  /* v8 ignore next -- @preserve */
975
1275
  if (apiSpec) {
@@ -1024,6 +1324,7 @@ async function buildAllApiPages(ecto, data) {
1024
1324
  async function renderApiContent(ecto, data) {
1025
1325
  const firstSpecUrl = data.openApiSpecs?.[0]?.url;
1026
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 */
1027
1328
  if (data.openApiSpecs && data.openApiSpecs.length > 0) return renderCombinedApiContent(ecto, data);
1028
1329
  /* v8 ignore start -- @preserve */
1029
1330
  const swaggerSource = `${data.sitePath}/api/swagger.json`;
@@ -1034,7 +1335,7 @@ async function renderApiContent(ecto, data) {
1034
1335
  const localSpec = await getSafeLocalOpenApiSpec(data);
1035
1336
  if (localSpec) apiSpec = parseOpenApiSpec(localSpec.content);
1036
1337
  else if (firstSpecUrl && isRemoteUrl(firstSpecUrl)) try {
1037
- apiSpec = parseOpenApiSpec(await (await fetch(firstSpecUrl)).text());
1338
+ apiSpec = parseOpenApiSpec(await (await safeFetch(firstSpecUrl)).text());
1038
1339
  } catch {}
1039
1340
  if (apiSpec) {
1040
1341
  apiSpec.info.description = new Writr$1(apiSpec.info.description, writrOptions$4).renderSync();
@@ -1120,6 +1421,7 @@ function hashOptions(hash, options) {
1120
1421
  template: options.template,
1121
1422
  templatePath: options.templatePath,
1122
1423
  enableLlmsTxt: options.enableLlmsTxt,
1424
+ enableSearch: options.enableSearch,
1123
1425
  changelogPerPage: options.changelogPerPage,
1124
1426
  enableReleaseChangelog: options.enableReleaseChangelog,
1125
1427
  sections: options.sections,
@@ -2112,6 +2414,153 @@ async function generateLlmsFullContent(data) {
2112
2414
  return lines.join("\n");
2113
2415
  }
2114
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
2115
2564
  //#region src/console.ts
2116
2565
  const ansiRegex = /\u001B\[[0-9;]*m/g;
2117
2566
  var DoculaConsole = class {
@@ -2513,6 +2962,11 @@ var DoculaOptions = class {
2513
2962
  */
2514
2963
  enableLlmsTxt = true;
2515
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
+ /**
2516
2970
  * Override the default theme toggle. By default the site follows the system
2517
2971
  * preference. Set to "light" or "dark" to use that theme when no user
2518
2972
  * preference is stored in localStorage.
@@ -2671,6 +3125,7 @@ var DoculaOptions = class {
2671
3125
  if (options.enableReleaseChangelog !== void 0 && typeof options.enableReleaseChangelog === "boolean") this.enableReleaseChangelog = options.enableReleaseChangelog;
2672
3126
  if (options.changelogPerPage !== void 0 && typeof options.changelogPerPage === "number" && options.changelogPerPage > 0) this.changelogPerPage = options.changelogPerPage;
2673
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;
2674
3129
  if (options.themeMode !== void 0 && (options.themeMode === "light" || options.themeMode === "dark")) this.themeMode = options.themeMode;
2675
3130
  if (options.autoUpdateIgnores !== void 0 && typeof options.autoUpdateIgnores === "boolean") this.autoUpdateIgnores = options.autoUpdateIgnores;
2676
3131
  if (options.autoReadme !== void 0 && typeof options.autoReadme === "boolean") this.autoReadme = options.autoReadme;
@@ -2900,6 +3355,7 @@ var DoculaBuilder = class {
2900
3355
  });
2901
3356
  doculaData.changelogEntries = allChangelogEntries;
2902
3357
  doculaData.hasChangelog = allChangelogEntries.length > 0 && hasChangelogTemplate;
3358
+ doculaData.enableSearch = Boolean(this.options.enableSearch && (doculaData.hasDocuments || doculaData.hasChangelog));
2903
3359
  /* v8 ignore next 40 -- @preserve */
2904
3360
  if (this._options.ai) {
2905
3361
  const aiModel = await createAIModel(this._options.ai);
@@ -3011,6 +3467,7 @@ var DoculaBuilder = class {
3011
3467
  /* v8 ignore next 3 -- @preserve */
3012
3468
  if (this.options.enableLlmsTxt) this._console.step("Building LLM files...");
3013
3469
  await this.buildLlmsFiles(doculaData);
3470
+ await this.buildSearchIndex(doculaData);
3014
3471
  ensureCacheInGitignore(this._options, this._console, this.options.sitePath);
3015
3472
  const newManifest = {
3016
3473
  version: 1,
@@ -3041,6 +3498,7 @@ var DoculaBuilder = class {
3041
3498
  const cwdReadmePath = path.join(cwdDir, "README.md");
3042
3499
  if (!fs.existsSync(cwdReadmePath)) return;
3043
3500
  let readmeContent = await fs.promises.readFile(cwdReadmePath, "utf8");
3501
+ /* v8 ignore next -- split() always yields at least one element; ?? is defensive -- @preserve */
3044
3502
  const firstLine = readmeContent.trimStart().split("\n")[0] ?? "";
3045
3503
  if (!/^#\s+/.test(firstLine)) {
3046
3504
  const packageJsonPath = path.join(cwdDir, "package.json");
@@ -3119,6 +3577,9 @@ var DoculaBuilder = class {
3119
3577
  async buildLlmsFiles(data) {
3120
3578
  return buildLlmsFiles(this._options, this._console, data);
3121
3579
  }
3580
+ async buildSearchIndex(data) {
3581
+ return buildSearchIndex(this._console, data);
3582
+ }
3122
3583
  resolveOpenGraphData(data, pageUrl, pageData) {
3123
3584
  return resolveOpenGraphData(data, pageUrl, pageData);
3124
3585
  }
@@ -3360,14 +3821,14 @@ var Docula = class {
3360
3821
  this._console.printHelp();
3361
3822
  return;
3362
3823
  }
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
3824
  if (consoleProcess.command === "version") {
3368
3825
  this._console.log(this.getVersion());
3369
3826
  return;
3370
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;
3371
3832
  if (this._configFileModule.onPrepare) try {
3372
3833
  await this._configFileModule.onPrepare(this.options, this._console);
3373
3834
  } catch (error) {
@@ -3526,29 +3987,37 @@ var Docula = class {
3526
3987
  */
3527
3988
  async loadConfigFile(sitePath) {
3528
3989
  if (!fs.existsSync(sitePath)) return;
3990
+ const jsonConfigFile = `${sitePath}/docula.config.json`;
3529
3991
  const tsConfigFile = `${sitePath}/docula.config.ts`;
3530
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 */
3531
4004
  /* v8 ignore next -- @preserve */
3532
4005
  if (fs.existsSync(tsConfigFile)) {
3533
4006
  const absolutePath = path.resolve(tsConfigFile);
3534
- const fileUrl = pathToFileURL(absolutePath).href;
3535
- if (isSEA()) try {
3536
- const mod = await import(fileUrl);
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
- }
4007
+ const { createJiti } = await import("jiti");
4008
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
4009
+ this._configFileModule = await jiti.import(absolutePath);
3546
4010
  return;
3547
4011
  }
3548
4012
  /* v8 ignore next -- @preserve */
3549
4013
  if (fs.existsSync(mjsConfigFile)) {
3550
4014
  const absolutePath = path.resolve(mjsConfigFile);
3551
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) };
3552
4021
  }
3553
4022
  }
3554
4023
  /**