docula 1.13.0 → 2.0.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 CHANGED
@@ -56,7 +56,7 @@ You can build Docula as a standalone binary that runs without Node.js installed.
56
56
 
57
57
  ## Building the Binary
58
58
 
59
- Requires Node.js >= 20 to build (the resulting binary does not need Node.js to run).
59
+ Requires Node.js >= 25.7.0 to build (the resulting binary does not need Node.js to run). The build uses [tsdown's `exe` option](https://tsdown.dev/options/exe), which wraps Node.js's [Single Executable Applications](https://nodejs.org/api/single-executable-applications.html) feature added in Node 25.7.
60
60
 
61
61
  ```bash
62
62
  pnpm install
@@ -69,7 +69,7 @@ This produces a platform-specific binary at `dist/docula` (or `dist/docula.exe`
69
69
 
70
70
  1. Embeds all built-in templates (modern, classic) into the bundle as base64
71
71
  2. Bundles all source code and dependencies into a single CJS file via [tsdown](https://tsdown.dev/)
72
- 3. Uses tsdown's built-in `exe` option to create a Node.js SEA binary (blob generation, injection, and code signing are handled automatically)
72
+ 3. Uses tsdown's built-in `exe` option to create a Node.js SEA binary blob generation, injection, and (on macOS hosts) ad-hoc code signing are handled automatically
73
73
 
74
74
  ## Testing the Binary
75
75
 
@@ -91,7 +91,7 @@ After building, test it locally:
91
91
 
92
92
  ## Cross-Platform Binaries
93
93
 
94
- Node.js SEA cannot cross-compile — the binary matches the OS and architecture it was built on. The CI workflow (`.github/workflows/build-binaries.yaml`) builds for all platforms using a matrix strategy:
94
+ The CI workflow (`.github/workflows/build-binaries.yaml`) builds each platform natively on its own runner. Native builds avoid the cross-compile signing pitfall on Apple Silicon, where unsigned Mach-O binaries are killed on launch.
95
95
 
96
96
  | Platform | Runner | Artifact |
97
97
  |---|---|---|
package/dist/docula.d.ts CHANGED
@@ -83,6 +83,7 @@ type ApiOperation = {
83
83
  id: string;
84
84
  method: string;
85
85
  methodUpper: string;
86
+ methodShort: string;
86
87
  path: string;
87
88
  summary: string;
88
89
  description: string;
@@ -214,6 +215,7 @@ type DoculaData = {
214
215
  changelogUrl: string;
215
216
  editPageUrl?: string;
216
217
  openGraph?: DoculaOpenGraph;
218
+ faviconUrl?: string;
217
219
  };
218
220
  type DoculaTemplates = {
219
221
  home: string;
@@ -448,6 +450,7 @@ declare class DoculaBuilder {
448
450
  private readonly _console;
449
451
  private readonly _hash;
450
452
  onReleaseChangelog?: (entries: DoculaChangelogEntry[], console: DoculaConsole) => Promise<DoculaChangelogEntry[]> | DoculaChangelogEntry[];
453
+ onAutoReadme?: (content: string, sourcePath: string, console: DoculaConsole) => Promise<string> | string;
451
454
  get console(): DoculaConsole;
452
455
  constructor(options?: DoculaBuilderOptions, engineOptions?: any);
453
456
  get options(): DoculaOptions;
package/dist/docula.js CHANGED
@@ -11,8 +11,9 @@ import { Writr, Writr as Writr$1 } from "writr";
11
11
  import { blue, bold, cyan, dim, gray, green, magenta, red, white, yellow } from "colorette";
12
12
  import { CacheableNet } from "@cacheable/net";
13
13
  import os from "node:os";
14
+ import sea from "node:sea";
14
15
  //#region package.json
15
- var version = "1.13.0";
16
+ var version = "2.0.0";
16
17
  var package_default = {
17
18
  name: "docula",
18
19
  version,
@@ -27,12 +28,12 @@ var package_default = {
27
28
  "url": "git+https://github.com/jaredwray/docula.git"
28
29
  },
29
30
  author: "Jared Wray <me@jaredwray.com>",
30
- engines: { "node": ">=20" },
31
+ engines: { "node": "^22.18.0 || >=24.0.0" },
31
32
  license: "MIT",
32
33
  scripts: {
33
34
  "clean": "rimraf ./dist ./coverage ./node_modules ./package-lock.json ./yarn.lock ./pnpm-lock.yaml ./site/dist",
34
35
  "build": "pnpm generate-init-file && tsdown",
35
- "build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts && tsx scripts/build-sea.ts",
36
+ "build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts",
36
37
  "generate-embedded-templates": "tsx scripts/generate-embedded-templates.ts",
37
38
  "lint": "biome check --write --error-on-warnings",
38
39
  "lint:ci": "biome check --error-on-warnings",
@@ -69,42 +70,39 @@ var package_default = {
69
70
  ],
70
71
  bin: { "docula": "./bin/docula.js" },
71
72
  dependencies: {
72
- "@ai-sdk/anthropic": "^3.0.63",
73
- "@ai-sdk/google": "^3.0.52",
74
- "@ai-sdk/openai": "^3.0.47",
75
- "@cacheable/net": "^2.0.6",
76
- "ai": "^6.0.134",
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
78
  "colorette": "^2.0.20",
78
- "ecto": "^4.8.3",
79
- "feed": "^5.2.0",
80
- "hashery": "^1.5.1",
81
- "jiti": "^2.6.1",
79
+ "ecto": "^4.8.5",
80
+ "hashery": "^2.0.0",
81
+ "jiti": "^2.7.0",
82
82
  "serve-handler": "^6.1.7",
83
83
  "update-notifier": "^7.3.1",
84
- "writr": "^6.1.0"
84
+ "writr": "^6.1.2"
85
85
  },
86
86
  devDependencies: {
87
- "@biomejs/biome": "^2.4.8",
88
- "@playwright/test": "^1.58.2",
89
- "@types/express": "^5.0.6",
90
- "@types/js-yaml": "^4.0.9",
91
- "@types/node": "^25.5.0",
87
+ "@biomejs/biome": "^2.4.15",
88
+ "@playwright/test": "^1.60.0",
89
+ "@types/node": "^24.12.4",
92
90
  "@types/serve-handler": "^6.1.4",
93
91
  "@types/update-notifier": "^6.0.8",
94
- "@vitest/coverage-v8": "^4.1.0",
95
- "dotenv": "^17.3.1",
96
- "postject": "1.0.0-alpha.6",
92
+ "@vitest/coverage-v8": "^4.1.6",
93
+ "dotenv": "^17.4.2",
97
94
  "rimraf": "^6.1.3",
98
- "tsdown": "^0.21.7",
95
+ "tsdown": "^0.22.0",
99
96
  "tsx": "^4.21.0",
100
- "typescript": "^5.9.3",
101
- "vitest": "^4.1.0"
97
+ "typescript": "^6.0.3",
98
+ "vitest": "^4.1.6"
102
99
  },
103
100
  files: [
104
101
  "dist",
105
102
  "templates",
106
103
  "bin"
107
- ]
104
+ ],
105
+ packageManager: "pnpm@11.1.3+sha512.c85357fe17ca12dd23dd7071822666dfd7e3cb76fe214e3370b5ea2fb34f2a231185509b63e717f3cd0acb38dd3f8d82bcd5e8172400ae678b70ea4fbed0896d"
108
106
  };
109
107
  //#endregion
110
108
  //#region src/builder-ai.ts
@@ -121,15 +119,15 @@ async function createAIModel(ai) {
121
119
  switch (ai.provider) {
122
120
  case "anthropic": {
123
121
  const { createAnthropic } = await import("@ai-sdk/anthropic");
124
- return createAnthropic({ apiKey: ai.apiKey })(ai.model ?? "claude-haiku-4-5");
122
+ return createAnthropic(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "claude-haiku-4-5");
125
123
  }
126
124
  case "openai": {
127
125
  const { createOpenAI } = await import("@ai-sdk/openai");
128
- return createOpenAI({ apiKey: ai.apiKey })(ai.model ?? "gpt-4o-mini-latest");
126
+ return createOpenAI(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "gpt-4o-mini");
129
127
  }
130
128
  case "google": {
131
129
  const { createGoogleGenerativeAI } = await import("@ai-sdk/google");
132
- return createGoogleGenerativeAI({ apiKey: ai.apiKey })(ai.model ?? "gemini-2.5-flash-lite");
130
+ return createGoogleGenerativeAI(ai.apiKey ? { apiKey: ai.apiKey } : {})(ai.model || "gemini-2.5-flash-lite");
133
131
  }
134
132
  default: return;
135
133
  }
@@ -379,10 +377,13 @@ function parseOpenApiSpec(specJson) {
379
377
  const requestBody = extractRequestBody(operation, spec);
380
378
  const responses = extractResponses(operation, spec);
381
379
  const codeExamples = generateCodeExamples(method, pathStr, servers.length > 0 ? servers[0].url : "", parameters, requestBody);
380
+ const operationId = operation.operationId ?? `${method}-${pathStr.replaceAll(/[^a-zA-Z0-9]/g, "-")}`;
381
+ const methodUpper = method.toUpperCase();
382
382
  const apiOperation = {
383
- id: slugify(operation.operationId ?? `${method}-${pathStr.replaceAll(/[^a-zA-Z0-9]/g, "-")}`),
383
+ id: slugify(operationId),
384
384
  method,
385
- methodUpper: method.toUpperCase(),
385
+ methodUpper,
386
+ methodShort: methodUpper === "DELETE" ? "DEL" : methodUpper === "OPTIONS" ? "OPT" : methodUpper,
386
387
  path: pathStr,
387
388
  summary: operation.summary ?? "",
388
389
  description: operation.description ?? "",
@@ -597,6 +598,7 @@ function extractResponses(operation, spec) {
597
598
  responses.push({
598
599
  statusCode,
599
600
  statusClass: getStatusClass(statusCode),
601
+ /* v8 ignore next */
600
602
  description: response.description ?? "",
601
603
  contentType,
602
604
  schemaProperties,
@@ -1093,6 +1095,22 @@ function hashFile(hash, filePath) {
1093
1095
  const content = fs.readFileSync(filePath);
1094
1096
  return hash.toHashSync(content);
1095
1097
  }
1098
+ function tryHashFile(hash, filePath) {
1099
+ try {
1100
+ return hashFile(hash, filePath);
1101
+ } catch (error) {
1102
+ if (error.code === "ENOENT") return;
1103
+ /* v8 ignore next 2 -- @preserve */
1104
+ throw error;
1105
+ }
1106
+ }
1107
+ function hashConfigFile(hash, sitePath) {
1108
+ for (const name of ["docula.config.ts", "docula.config.mjs"]) {
1109
+ const configPath = path.join(sitePath, name);
1110
+ if (fs.existsSync(configPath)) return hashFile(hash, configPath);
1111
+ }
1112
+ return "";
1113
+ }
1096
1114
  function hashOptions(hash, options) {
1097
1115
  const relevant = {
1098
1116
  siteUrl: options.siteUrl,
@@ -1119,19 +1137,29 @@ function hashOptions(hash, options) {
1119
1137
  ai: options.ai,
1120
1138
  googleTagManager: options.googleTagManager
1121
1139
  };
1122
- return hash.toHashSync(JSON.stringify(relevant));
1140
+ const optionsHash = hash.toHashSync(JSON.stringify(relevant));
1141
+ const configHash = hashConfigFile(hash, options.sitePath);
1142
+ return hash.toHashSync(`${optionsHash}:${configHash}`);
1123
1143
  }
1124
1144
  function hashTemplateDirectory(hash, templatePath) {
1125
1145
  /* v8 ignore next 3 -- @preserve */
1126
1146
  if (!fs.existsSync(templatePath)) return "";
1127
- const hashes = listFilesRecursive(templatePath).map((f) => hashFile(hash, path.join(templatePath, f)));
1147
+ const files = listFilesRecursive(templatePath);
1148
+ const hashes = [];
1149
+ for (const f of files) {
1150
+ const fileHash = tryHashFile(hash, path.join(templatePath, f));
1151
+ if (fileHash !== void 0) hashes.push(fileHash);
1152
+ }
1128
1153
  return hash.toHashSync(hashes.join(""));
1129
1154
  }
1130
1155
  function hashSourceFiles(hash, dir) {
1131
1156
  const hashes = {};
1132
1157
  if (!fs.existsSync(dir)) return hashes;
1133
1158
  const files = listFilesRecursive(dir);
1134
- for (const file of files) hashes[file] = hashFile(hash, path.join(dir, file));
1159
+ for (const file of files) {
1160
+ const fileHash = tryHashFile(hash, path.join(dir, file));
1161
+ if (fileHash !== void 0) hashes[file] = fileHash;
1162
+ }
1135
1163
  return hashes;
1136
1164
  }
1137
1165
  function recordsEqual(a, b) {
@@ -1145,6 +1173,7 @@ function hasAssetsChanged(hash, sitePath, previousAssets, autoReadme) {
1145
1173
  for (const file of [
1146
1174
  "favicon.ico",
1147
1175
  "logo.svg",
1176
+ "logo.png",
1148
1177
  "logo_horizontal.png",
1149
1178
  "variables.css",
1150
1179
  "api/swagger.json",
@@ -2669,12 +2698,7 @@ var DoculaOptions = class {
2669
2698
  * Returns true when running as a single-executable application (SEA).
2670
2699
  */
2671
2700
  function isSEA() {
2672
- try {
2673
- return Boolean(process.sea);
2674
- } catch {
2675
- /* v8 ignore next -- @preserve */
2676
- return false;
2677
- }
2701
+ return sea.isSea();
2678
2702
  }
2679
2703
  /**
2680
2704
  * Returns the deterministic temp directory path for extracted templates.
@@ -2739,6 +2763,7 @@ var DoculaBuilder = class {
2739
2763
  _console;
2740
2764
  _hash = new Hashery();
2741
2765
  onReleaseChangelog;
2766
+ onAutoReadme;
2742
2767
  get console() {
2743
2768
  return this._console;
2744
2769
  }
@@ -2804,6 +2829,12 @@ var DoculaBuilder = class {
2804
2829
  editPageUrl: this.options.editPageUrl,
2805
2830
  openGraph: this.options.openGraph
2806
2831
  };
2832
+ const resolvedFavicon = [
2833
+ "favicon.ico",
2834
+ "logo.svg",
2835
+ "logo.png"
2836
+ ].find((file) => fs.existsSync(path.join(this.options.sitePath, file)));
2837
+ if (resolvedFavicon) doculaData.faviconUrl = buildUrlPath(this.options.baseUrl, resolvedFavicon);
2807
2838
  if (siteReadmeExists) currentAssetHashes["README.md"] = hashFile(this._hash, path.join(this.options.sitePath, "README.md"));
2808
2839
  else if (autoReadmeResult) currentAssetHashes.__autoReadme = hashFile(this._hash, autoReadmeResult.sourcePath);
2809
2840
  if (Array.isArray(this.options.openApiUrl)) doculaData.openApiSpecs = this.options.openApiUrl.map((spec) => ({
@@ -2939,6 +2970,10 @@ var DoculaBuilder = class {
2939
2970
  await fs.promises.copyFile(`${siteRelativePath}/logo.svg`, `${this.options.output}/logo.svg`);
2940
2971
  this._console.fileCopied("logo.svg");
2941
2972
  }
2973
+ if (!hashAssetAndCheckSkip(this._hash, path.join(siteRelativePath, "logo.png"), path.join(this.options.output, "logo.png"), "logo.png", previousAssets, currentAssetHashes)) {
2974
+ await fs.promises.copyFile(path.join(siteRelativePath, "logo.png"), path.join(this.options.output, "logo.png"));
2975
+ this._console.fileCopied("logo.png");
2976
+ }
2942
2977
  if (!hashAssetAndCheckSkip(this._hash, `${siteRelativePath}/logo_horizontal.png`, `${this.options.output}/logo_horizontal.png`, "logo_horizontal.png", previousAssets, currentAssetHashes)) {
2943
2978
  await fs.promises.copyFile(`${siteRelativePath}/logo_horizontal.png`, `${this.options.output}/logo_horizontal.png`);
2944
2979
  this._console.fileCopied("logo_horizontal.png");
@@ -3014,9 +3049,16 @@ var DoculaBuilder = class {
3014
3049
  if (packageJson.name && typeof packageJson.name === "string") readmeContent = `# ${packageJson.name}\n\n${readmeContent}`;
3015
3050
  } catch {}
3016
3051
  }
3052
+ let content = readmeContent;
3053
+ if (this.onAutoReadme) try {
3054
+ content = await this.onAutoReadme(content, cwdReadmePath, this._console);
3055
+ } catch (error) {
3056
+ const message = error instanceof Error ? error.message : String(error);
3057
+ this._console.error(`onAutoReadme error: ${message}`);
3058
+ }
3017
3059
  return {
3018
3060
  sourcePath: cwdReadmePath,
3019
- content: readmeContent
3061
+ content
3020
3062
  };
3021
3063
  }
3022
3064
  async getGithubData(githubPath) {
@@ -3396,6 +3438,8 @@ var Docula = class {
3396
3438
  const builder = new DoculaBuilder(Object.assign(this.options, { console: this._console }));
3397
3439
  /* v8 ignore next 4 -- @preserve */
3398
3440
  if (this._configFileModule.onReleaseChangelog) builder.onReleaseChangelog = this._configFileModule.onReleaseChangelog;
3441
+ /* v8 ignore next 4 -- @preserve */
3442
+ if (this._configFileModule.onAutoReadme) builder.onAutoReadme = this._configFileModule.onAutoReadme;
3399
3443
  await builder.build();
3400
3444
  return builder;
3401
3445
  }
@@ -3496,12 +3540,16 @@ var Docula = class {
3496
3540
  }
3497
3541
  else {
3498
3542
  const { createJiti } = await import("jiti");
3499
- this._configFileModule = await createJiti(import.meta.url, { interopDefault: true }).import(absolutePath);
3543
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
3544
+ this._configFileModule = await jiti.import(absolutePath);
3500
3545
  }
3501
3546
  return;
3502
3547
  }
3503
3548
  /* v8 ignore next -- @preserve */
3504
- if (fs.existsSync(mjsConfigFile)) this._configFileModule = await import(pathToFileURL(path.resolve(mjsConfigFile)).href);
3549
+ if (fs.existsSync(mjsConfigFile)) {
3550
+ const absolutePath = path.resolve(mjsConfigFile);
3551
+ this._configFileModule = await import(pathToFileURL(absolutePath).href);
3552
+ }
3505
3553
  }
3506
3554
  /**
3507
3555
  * Watch the site path for file changes and rebuild on change
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docula",
3
- "version": "1.13.0",
3
+ "version": "2.0.0",
4
4
  "description": "Beautiful Website for Your Projects",
5
5
  "type": "module",
6
6
  "main": "./dist/docula.js",
@@ -17,7 +17,7 @@
17
17
  },
18
18
  "author": "Jared Wray <me@jaredwray.com>",
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": "^22.18.0 || >=24.0.0"
21
21
  },
22
22
  "license": "MIT",
23
23
  "keywords": [
@@ -40,36 +40,32 @@
40
40
  "docula": "./bin/docula.js"
41
41
  },
42
42
  "dependencies": {
43
- "@ai-sdk/anthropic": "^3.0.63",
44
- "@ai-sdk/google": "^3.0.52",
45
- "@ai-sdk/openai": "^3.0.47",
46
- "@cacheable/net": "^2.0.6",
47
- "ai": "^6.0.134",
43
+ "@ai-sdk/anthropic": "^3.0.77",
44
+ "@ai-sdk/google": "^3.0.72",
45
+ "@ai-sdk/openai": "^3.0.63",
46
+ "@cacheable/net": "^2.0.7",
47
+ "ai": "^6.0.178",
48
48
  "colorette": "^2.0.20",
49
- "ecto": "^4.8.3",
50
- "feed": "^5.2.0",
51
- "hashery": "^1.5.1",
52
- "jiti": "^2.6.1",
49
+ "ecto": "^4.8.5",
50
+ "hashery": "^2.0.0",
51
+ "jiti": "^2.7.0",
53
52
  "serve-handler": "^6.1.7",
54
53
  "update-notifier": "^7.3.1",
55
- "writr": "^6.1.0"
54
+ "writr": "^6.1.2"
56
55
  },
57
56
  "devDependencies": {
58
- "@biomejs/biome": "^2.4.8",
59
- "@playwright/test": "^1.58.2",
60
- "@types/express": "^5.0.6",
61
- "@types/js-yaml": "^4.0.9",
62
- "@types/node": "^25.5.0",
57
+ "@biomejs/biome": "^2.4.15",
58
+ "@playwright/test": "^1.60.0",
59
+ "@types/node": "^24.12.4",
63
60
  "@types/serve-handler": "^6.1.4",
64
61
  "@types/update-notifier": "^6.0.8",
65
- "@vitest/coverage-v8": "^4.1.0",
66
- "dotenv": "^17.3.1",
67
- "postject": "1.0.0-alpha.6",
62
+ "@vitest/coverage-v8": "^4.1.6",
63
+ "dotenv": "^17.4.2",
68
64
  "rimraf": "^6.1.3",
69
- "tsdown": "^0.21.7",
65
+ "tsdown": "^0.22.0",
70
66
  "tsx": "^4.21.0",
71
- "typescript": "^5.9.3",
72
- "vitest": "^4.1.0"
67
+ "typescript": "^6.0.3",
68
+ "vitest": "^4.1.6"
73
69
  },
74
70
  "files": [
75
71
  "dist",
@@ -79,7 +75,7 @@
79
75
  "scripts": {
80
76
  "clean": "rimraf ./dist ./coverage ./node_modules ./package-lock.json ./yarn.lock ./pnpm-lock.yaml ./site/dist",
81
77
  "build": "pnpm generate-init-file && tsdown",
82
- "build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts && tsx scripts/build-sea.ts",
78
+ "build:binary": "pnpm generate-init-file && pnpm generate-embedded-templates && tsdown --config tsdown.config.binary.ts",
83
79
  "generate-embedded-templates": "tsx scripts/generate-embedded-templates.ts",
84
80
  "lint": "biome check --write --error-on-warnings",
85
81
  "lint:ci": "biome check --error-on-warnings",
@@ -31,4 +31,4 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
31
31
  rel="stylesheet"
32
32
  />
33
33
  <link rel="stylesheet" href="/css/highlight/styles/base16/dracula.min.css" />
34
- <link rel="icon" href="/favicon.ico" />
34
+ {{#if faviconUrl}}<link rel="icon" href="{{faviconUrl}}" />{{/if}}
@@ -39,8 +39,8 @@
39
39
  <div class="api-sidebar__group-items">
40
40
  {{#each this.operations}}
41
41
  <a href="#{{this.id}}" class="api-sidebar__item" data-method="{{this.method}}" data-path="{{this.path}}">
42
- <span class="method-badge method-badge--{{this.method}}">{{this.methodUpper}}</span>
43
42
  <span class="api-sidebar__item-path">{{this.path}}</span>
43
+ <span class="method-badge method-badge--{{this.method}}">{{this.methodShort}}</span>
44
44
  </a>
45
45
  {{/each}}
46
46
  </div>
@@ -100,17 +100,20 @@
100
100
  }
101
101
 
102
102
  .api-sidebar__group-items {
103
- padding: 2px 0 8px 0;
103
+ padding: 4px 0 8px 12px;
104
+ margin-left: 10px;
105
+ border-left: 1px solid var(--border);
104
106
  }
105
107
 
106
108
  .api-sidebar__item {
107
109
  display: flex;
108
110
  align-items: center;
109
- gap: 8px;
110
- padding: 4px 10px;
111
+ justify-content: space-between;
112
+ gap: 12px;
113
+ padding: 8px 10px;
111
114
  font-size: 13px;
112
115
  border-radius: 4px;
113
- color: var(--fg);
116
+ color: var(--muted-fg);
114
117
  white-space: nowrap;
115
118
  overflow: hidden;
116
119
  text-overflow: ellipsis;
@@ -125,8 +128,23 @@
125
128
  }
126
129
 
127
130
  .api-sidebar__item-path {
131
+ min-width: 0;
128
132
  overflow: hidden;
129
133
  text-overflow: ellipsis;
134
+ color: var(--muted-fg);
135
+ }
136
+
137
+ .api-sidebar__item--active .api-sidebar__item-path {
138
+ color: var(--fg);
139
+ }
140
+
141
+ .api-sidebar__item .method-badge {
142
+ background: transparent;
143
+ padding: 0;
144
+ min-width: 0;
145
+ border-radius: 0;
146
+ font-size: 11px;
147
+ letter-spacing: 0.5px;
130
148
  }
131
149
 
132
150
  /* Method Badges */
@@ -289,6 +289,44 @@ body {
289
289
  letter-spacing: 0.24px;
290
290
  }
291
291
 
292
+ .nav-sidebar__title:has(.nav-sidebar__toggle) {
293
+ padding: 0;
294
+ }
295
+
296
+ .nav-sidebar__toggle {
297
+ all: unset;
298
+ box-sizing: border-box;
299
+ display: flex;
300
+ align-items: center;
301
+ justify-content: space-between;
302
+ width: 100%;
303
+ cursor: pointer;
304
+ padding: 0 8px 0 10px;
305
+ font: inherit;
306
+ color: inherit;
307
+ text-transform: inherit;
308
+ letter-spacing: inherit;
309
+ }
310
+
311
+ .nav-sidebar__toggle:focus-visible {
312
+ outline: 2px solid var(--accent, currentColor);
313
+ outline-offset: 2px;
314
+ border-radius: 4px;
315
+ }
316
+
317
+ .nav-sidebar__chevron {
318
+ flex-shrink: 0;
319
+ transition: transform 150ms ease;
320
+ }
321
+
322
+ .nav-sidebar__section--collapsed .nav-sidebar__chevron {
323
+ transform: rotate(-90deg);
324
+ }
325
+
326
+ .nav-sidebar__section--collapsed .nav-sidebar__list {
327
+ display: none;
328
+ }
329
+
292
330
  .nav-sidebar__list {
293
331
  margin-block: 2px;
294
332
  }
@@ -360,7 +398,7 @@ body {
360
398
  .copy-code-btn { position: absolute; top: 8px; right: 8px; padding: 4px; line-height: 0; border-radius: 4px; background: transparent; color: var(--muted); border: none; cursor: pointer; opacity: 0; transition: opacity 0.15s; }
361
399
  pre:hover .copy-code-btn { opacity: 1; }
362
400
  .copy-code-btn:hover { color: var(--fg); }
363
- .article__main img, .changelog-entry-body img { cursor: zoom-in; }
401
+ .article__main img:not(a img), .changelog-entry-body img:not(a img) { cursor: zoom-in; }
364
402
  .lightbox-overlay { display: none; position: fixed; inset: 0; z-index: 200; background: rgba(0, 0, 0, 0.8); justify-content: center; align-items: center; cursor: pointer; }
365
403
  .lightbox-overlay--visible { display: flex !important; }
366
404
  .lightbox-overlay img { max-width: 90vw; max-height: 90vh; border-radius: 8px; box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4); cursor: default; }
@@ -22,7 +22,7 @@ j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src=
22
22
  <link rel="stylesheet" href="{{baseUrl}}/css/variables.css">
23
23
  <link rel="stylesheet" href="{{baseUrl}}/css/styles.css">
24
24
  <link rel="stylesheet" href="{{baseUrl}}/css/highlight/styles/base16/docula.css">
25
- <link rel="icon" href="{{baseUrl}}/favicon.ico">
25
+ {{#if faviconUrl}}<link rel="icon" href="{{faviconUrl}}">{{/if}}
26
26
  <script>
27
27
  (function(){
28
28
  window.__doculaThemeKey = 'docula:theme:' + ({{#if siteUrl}}'{{siteUrl}}'{{else}}location.origin{{/if}}).replace(/^https?:\/\//, '');
@@ -227,6 +227,44 @@
227
227
  }
228
228
  });
229
229
 
230
+ // Sidebar section collapse/expand
231
+ const SIDEBAR_STORAGE_KEY = 'docula:sidebar-sections';
232
+ const collapsibleSections = document.querySelectorAll('.nav-sidebar__section--collapsible');
233
+ if (collapsibleSections.length > 0) {
234
+ let storedSectionState = {};
235
+ try {
236
+ storedSectionState = JSON.parse(localStorage.getItem(SIDEBAR_STORAGE_KEY) || '{}');
237
+ } catch (e) { storedSectionState = {}; }
238
+
239
+ const setSectionOpen = (section, toggle, open) => {
240
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
241
+ section.classList.toggle('nav-sidebar__section--collapsed', !open);
242
+ };
243
+
244
+ collapsibleSections.forEach((section, idx) => {
245
+ const toggle = section.querySelector('.nav-sidebar__toggle');
246
+ const list = section.querySelector('.nav-sidebar__list');
247
+ if (!toggle || !list) return;
248
+ const listId = 'nav-sidebar-section-' + idx;
249
+ list.id = listId;
250
+ toggle.setAttribute('aria-controls', listId);
251
+
252
+ const key = 'section-' + idx;
253
+ const hasStored = Object.prototype.hasOwnProperty.call(storedSectionState, key);
254
+ const isOpen = hasStored ? !!storedSectionState[key] : true;
255
+ setSectionOpen(section, toggle, isOpen);
256
+
257
+ toggle.addEventListener('click', () => {
258
+ const next = toggle.getAttribute('aria-expanded') !== 'true';
259
+ setSectionOpen(section, toggle, next);
260
+ storedSectionState[key] = next;
261
+ try {
262
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(storedSectionState));
263
+ } catch (e) { /* storage unavailable */ }
264
+ });
265
+ });
266
+ }
267
+
230
268
  // Active header nav link highlighting
231
269
  const navLinks = document.querySelectorAll('.header-bottom__item');
232
270
  navLinks.forEach((link) => {
@@ -282,6 +320,7 @@
282
320
  document.body.appendChild(lightboxOverlay);
283
321
 
284
322
  document.querySelectorAll('.article__main img, .changelog-entry-body img').forEach(function(img) {
323
+ if (img.closest('a')) return;
285
324
  img.addEventListener('click', function() {
286
325
  lightboxImg.src = img.src;
287
326
  lightboxOverlay.classList.add('lightbox-overlay--visible');
@@ -1,7 +1,12 @@
1
1
  {{#forEach sidebarItems}}
2
2
  {{#if children}}
3
- <section class="nav-sidebar__section">
4
- <h2 class="nav-sidebar__title">{{name}}</h2>
3
+ <section class="nav-sidebar__section nav-sidebar__section--collapsible">
4
+ <h2 class="nav-sidebar__title">
5
+ <button type="button" class="nav-sidebar__toggle" aria-expanded="true">
6
+ <span class="nav-sidebar__toggle-label">{{name}}</span>
7
+ <svg class="nav-sidebar__chevron" xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>
8
+ </button>
9
+ </h2>
5
10
  <ul class="nav-sidebar__list">
6
11
  {{#forEach children}}
7
12
  <li>