docula 1.12.0 → 1.14.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.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;
@@ -153,6 +154,10 @@ type DoculaChangelogEntry = {
153
154
  previewImage?: string;
154
155
  urlPath: string;
155
156
  lastModified: string;
157
+ description?: string;
158
+ keywords?: string[];
159
+ ogTitle?: string;
160
+ ogDescription?: string;
156
161
  };
157
162
  type DoculaData = {
158
163
  siteUrl: string;
@@ -175,6 +180,13 @@ type DoculaData = {
175
180
  openApiSpecs?: DoculaOpenApiSpecEntry[];
176
181
  changelogEntries?: DoculaChangelogEntry[];
177
182
  hasReadme?: boolean;
183
+ readmeContent?: string;
184
+ readmeMetadata?: {
185
+ description?: string;
186
+ keywords?: string[];
187
+ ogTitle?: string;
188
+ ogDescription?: string;
189
+ };
178
190
  themeMode?: string;
179
191
  cookieAuth?: {
180
192
  loginUrl: string;
@@ -271,7 +283,7 @@ type DoculaCacheOptions = {
271
283
  type DoculaAIOptions = {
272
284
  provider: string;
273
285
  model?: string;
274
- apiKey: string;
286
+ apiKey?: string;
275
287
  };
276
288
  declare class DoculaOptions {
277
289
  /**
@@ -345,9 +357,10 @@ declare class DoculaOptions {
345
357
  */
346
358
  autoUpdateIgnores: boolean;
347
359
  /**
348
- * When true, automatically copies the project root README.md into the site
349
- * directory if one does not already exist. The package.json name field is
350
- * used to prepend a title heading when the README lacks one.
360
+ * When true, automatically renders the project root README.md as the home
361
+ * page if no README exists in the site directory. The README is read in
362
+ * place and never copied into the site directory. The package.json name
363
+ * field is used to prepend a title heading when the README lacks one.
351
364
  */
352
365
  autoReadme: boolean;
353
366
  /**
@@ -436,12 +449,16 @@ declare class DoculaBuilder {
436
449
  private readonly _console;
437
450
  private readonly _hash;
438
451
  onReleaseChangelog?: (entries: DoculaChangelogEntry[], console: DoculaConsole) => Promise<DoculaChangelogEntry[]> | DoculaChangelogEntry[];
452
+ onAutoReadme?: (content: string, sourcePath: string, console: DoculaConsole) => Promise<string> | string;
439
453
  get console(): DoculaConsole;
440
454
  constructor(options?: DoculaBuilderOptions, engineOptions?: any);
441
455
  get options(): DoculaOptions;
442
456
  build(): Promise<void>;
443
457
  validateOptions(options: DoculaOptions): void;
444
- autoReadme(): Promise<void>;
458
+ autoReadme(): Promise<{
459
+ sourcePath: string;
460
+ content: string;
461
+ } | undefined>;
445
462
  getGithubData(githubPath: string): Promise<GithubData>;
446
463
  getTemplates(templatePath: string, hasDocuments: boolean, hasChangelog?: boolean): Promise<DoculaTemplates>;
447
464
  getTemplateFile(path: string, name: string): Promise<string | undefined>;
package/dist/docula.js CHANGED
@@ -12,7 +12,7 @@ import { blue, bold, cyan, dim, gray, green, magenta, red, white, yellow } from
12
12
  import { CacheableNet } from "@cacheable/net";
13
13
  import os from "node:os";
14
14
  //#region package.json
15
- var version = "1.12.0";
15
+ var version = "1.14.0";
16
16
  var package_default = {
17
17
  name: "docula",
18
18
  version,
@@ -69,36 +69,33 @@ var package_default = {
69
69
  ],
70
70
  bin: { "docula": "./bin/docula.js" },
71
71
  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",
72
+ "@ai-sdk/anthropic": "^3.0.69",
73
+ "@ai-sdk/google": "^3.0.63",
74
+ "@ai-sdk/openai": "^3.0.53",
75
+ "@cacheable/net": "^2.0.7",
76
+ "ai": "^6.0.164",
77
77
  "colorette": "^2.0.20",
78
- "ecto": "^4.8.3",
79
- "feed": "^5.2.0",
80
- "hashery": "^1.5.1",
78
+ "ecto": "^4.8.4",
79
+ "hashery": "^2.0.0",
81
80
  "jiti": "^2.6.1",
82
81
  "serve-handler": "^6.1.7",
83
82
  "update-notifier": "^7.3.1",
84
- "writr": "^6.1.0"
83
+ "writr": "^6.1.1"
85
84
  },
86
85
  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",
86
+ "@biomejs/biome": "^2.4.12",
87
+ "@playwright/test": "^1.59.1",
88
+ "@types/node": "^25.6.0",
92
89
  "@types/serve-handler": "^6.1.4",
93
90
  "@types/update-notifier": "^6.0.8",
94
- "@vitest/coverage-v8": "^4.1.0",
95
- "dotenv": "^17.3.1",
91
+ "@vitest/coverage-v8": "^4.1.4",
92
+ "dotenv": "^17.4.2",
96
93
  "postject": "1.0.0-alpha.6",
97
94
  "rimraf": "^6.1.3",
98
- "tsdown": "^0.21.7",
95
+ "tsdown": "^0.21.9",
99
96
  "tsx": "^4.21.0",
100
- "typescript": "^5.9.3",
101
- "vitest": "^4.1.0"
97
+ "typescript": "^6.0.2",
98
+ "vitest": "^4.1.4"
102
99
  },
103
100
  files: [
104
101
  "dist",
@@ -161,13 +158,13 @@ function saveAIMetadataCache(sitePath, cache) {
161
158
  * Check if a document needs AI enrichment for OG/meta fields.
162
159
  */
163
160
  function needsDocumentEnrichment(doc) {
164
- return !doc.description || doc.keywords.length === 0 || !doc.ogTitle || !doc.ogDescription;
161
+ return !doc.description || doc.keywords.length === 0 || !doc.ogDescription;
165
162
  }
166
163
  /**
167
164
  * Check if a changelog entry needs AI enrichment.
168
165
  */
169
166
  function needsChangelogEnrichment(entry) {
170
- return !entry.title || !entry.preview;
167
+ return !entry.preview || !entry.description || !entry.keywords?.length || !entry.ogDescription;
171
168
  }
172
169
  /**
173
170
  * Enrich documents with AI-generated metadata for OG/meta tags.
@@ -183,9 +180,13 @@ async function enrichDocuments(documents, model, hash, console, cache) {
183
180
  const bodyHash = hash.toHashSync(doc.content);
184
181
  const cached = cache[bodyHash];
185
182
  if (cached) {
186
- enriched[i] = applyMetadataToDocument(doc, cached);
187
- logDocumentMetadata(console, doc.title || doc.documentPath, cached, true);
188
- continue;
183
+ const applied = applyMetadataToDocument(doc, cached);
184
+ if (!needsDocumentEnrichment(applied)) {
185
+ enriched[i] = applied;
186
+ logDocumentMetadata(console, doc.title || doc.documentPath, cached, true);
187
+ continue;
188
+ }
189
+ delete cache[bodyHash];
189
190
  }
190
191
  if (doc.content.trim().length < 10) continue;
191
192
  const metadata = await new Writr$1(doc.content, {
@@ -210,14 +211,19 @@ async function enrichChangelogEntries(entries, model, hash, console, cache) {
210
211
  const enriched = [...entries];
211
212
  for (let i = 0; i < enriched.length; i++) {
212
213
  const entry = enriched[i];
214
+ /* v8 ignore next -- @preserve */
213
215
  if (!needsChangelogEnrichment(entry)) continue;
214
216
  try {
215
217
  const bodyHash = hash.toHashSync(entry.content);
216
218
  const cached = cache[bodyHash];
217
219
  if (cached) {
218
- enriched[i] = applyMetadataToChangelog(entry, cached);
219
- logChangelogMetadata(console, entry.title || entry.slug, cached, true);
220
- continue;
220
+ const applied = applyMetadataToChangelog(entry, cached);
221
+ if (!needsChangelogEnrichment(applied)) {
222
+ enriched[i] = applied;
223
+ logChangelogMetadata(console, entry.title || entry.slug, cached, true);
224
+ continue;
225
+ }
226
+ delete cache[bodyHash];
221
227
  }
222
228
  if (entry.content.trim().length < 10) continue;
223
229
  const metadata = await new Writr$1(entry.content, {
@@ -235,6 +241,45 @@ async function enrichChangelogEntries(entries, model, hash, console, cache) {
235
241
  return enriched;
236
242
  }
237
243
  /**
244
+ * Enrich the site README with AI-generated metadata for OG/meta tags.
245
+ * Accepts the README content directly (from doculaData.readmeContent or
246
+ * by reading sitePath/README.md). Returns mapped metadata or undefined
247
+ * if content is missing, too small, or enrichment fails.
248
+ */
249
+ async function enrichReadme(content, model, hash, console, cache) {
250
+ if (!content) return;
251
+ try {
252
+ if (content.trim().length < 10) return;
253
+ const bodyHash = hash.toHashSync(content);
254
+ const cached = cache[bodyHash];
255
+ if (cached) {
256
+ logDocumentMetadata(console, "README", cached, true);
257
+ return {
258
+ description: cached.description,
259
+ keywords: cached.keywords,
260
+ ogTitle: cached.title,
261
+ ogDescription: cached.description
262
+ };
263
+ }
264
+ const metadata = await new Writr$1(content, {
265
+ ...writrOptions$6,
266
+ ai: { model }
267
+ }).ai?.getMetadata();
268
+ if (!metadata) return;
269
+ cache[bodyHash] = metadata;
270
+ logDocumentMetadata(console, "README", metadata, false);
271
+ return {
272
+ description: metadata.description,
273
+ keywords: metadata.keywords,
274
+ ogTitle: metadata.title,
275
+ ogDescription: metadata.description
276
+ };
277
+ } catch (error) {
278
+ console.warn(`AI enrichment failed for README: ${error.message}`);
279
+ return;
280
+ }
281
+ }
282
+ /**
238
283
  * Log AI-generated metadata for a document.
239
284
  */
240
285
  function truncate(value, max = 60) {
@@ -248,7 +293,6 @@ function logDocumentMetadata(console, name, metadata, fromCache) {
248
293
  console.info(`AI enriched: ${name}`);
249
294
  if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
250
295
  if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
251
- if (metadata.title) console.log(white(` ogTitle: ${truncate(metadata.title)}`));
252
296
  }
253
297
  /**
254
298
  * Log AI-generated metadata for a changelog entry.
@@ -259,8 +303,9 @@ function logChangelogMetadata(console, name, metadata, fromCache) {
259
303
  return;
260
304
  }
261
305
  console.info(`AI enriched changelog: ${name}`);
262
- if (metadata.title) console.log(white(` title: ${truncate(metadata.title)}`));
263
306
  if (metadata.preview || metadata.summary) console.log(white(` preview: ${truncate(metadata.preview || metadata.summary || "")}`));
307
+ if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
308
+ if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
264
309
  }
265
310
  /**
266
311
  * Apply AI-generated metadata to a document, filling only missing fields.
@@ -270,7 +315,7 @@ function applyMetadataToDocument(doc, metadata) {
270
315
  ...doc,
271
316
  description: doc.description || metadata.description || "",
272
317
  keywords: doc.keywords.length > 0 ? doc.keywords : metadata.keywords ?? [],
273
- ogTitle: doc.ogTitle ?? metadata.title,
318
+ ogTitle: doc.ogTitle ?? (doc.title || void 0),
274
319
  ogDescription: doc.ogDescription ?? metadata.description
275
320
  };
276
321
  }
@@ -280,8 +325,11 @@ function applyMetadataToDocument(doc, metadata) {
280
325
  function applyMetadataToChangelog(entry, metadata) {
281
326
  return {
282
327
  ...entry,
283
- title: entry.title || metadata.title || "",
284
- preview: entry.preview || metadata.preview || metadata.summary || ""
328
+ preview: entry.preview || metadata.preview || metadata.summary || "",
329
+ description: entry.description || metadata.description || void 0,
330
+ keywords: entry.keywords?.length ? entry.keywords : metadata.keywords ?? [],
331
+ ogTitle: entry.ogTitle ?? (entry.title || void 0),
332
+ ogDescription: entry.ogDescription ?? metadata.description
285
333
  };
286
334
  }
287
335
  //#endregion
@@ -328,10 +376,13 @@ function parseOpenApiSpec(specJson) {
328
376
  const requestBody = extractRequestBody(operation, spec);
329
377
  const responses = extractResponses(operation, spec);
330
378
  const codeExamples = generateCodeExamples(method, pathStr, servers.length > 0 ? servers[0].url : "", parameters, requestBody);
379
+ const operationId = operation.operationId ?? `${method}-${pathStr.replaceAll(/[^a-zA-Z0-9]/g, "-")}`;
380
+ const methodUpper = method.toUpperCase();
331
381
  const apiOperation = {
332
- id: slugify(operation.operationId ?? `${method}-${pathStr.replaceAll(/[^a-zA-Z0-9]/g, "-")}`),
382
+ id: slugify(operationId),
333
383
  method,
334
- methodUpper: method.toUpperCase(),
384
+ methodUpper,
385
+ methodShort: methodUpper === "DELETE" ? "DEL" : methodUpper === "OPTIONS" ? "OPT" : methodUpper,
335
386
  path: pathStr,
336
387
  summary: operation.summary ?? "",
337
388
  description: operation.description ?? "",
@@ -745,7 +796,7 @@ function resolveJsonLd(pageType, data, pageUrl, pageData) {
745
796
  "@context": "https://schema.org",
746
797
  "@type": "BlogPosting",
747
798
  headline: pageData.title,
748
- description: pageData?.preview ?? pageData?.description ?? "",
799
+ description: pageData?.description ?? pageData?.preview ?? "",
749
800
  url,
750
801
  publisher: {
751
802
  "@type": "Organization",
@@ -754,6 +805,7 @@ function resolveJsonLd(pageType, data, pageUrl, pageData) {
754
805
  };
755
806
  if (pageData?.date) schema.datePublished = pageData.date;
756
807
  if (pageData?.previewImage) schema.image = pageData.previewImage;
808
+ if (pageData?.keywords) schema.keywords = pageData.keywords;
757
809
  break;
758
810
  }
759
811
  /* v8 ignore next 3 -- @preserve */
@@ -1041,6 +1093,22 @@ function hashFile(hash, filePath) {
1041
1093
  const content = fs.readFileSync(filePath);
1042
1094
  return hash.toHashSync(content);
1043
1095
  }
1096
+ function tryHashFile(hash, filePath) {
1097
+ try {
1098
+ return hashFile(hash, filePath);
1099
+ } catch (error) {
1100
+ if (error.code === "ENOENT") return;
1101
+ /* v8 ignore next 2 -- @preserve */
1102
+ throw error;
1103
+ }
1104
+ }
1105
+ function hashConfigFile(hash, sitePath) {
1106
+ for (const name of ["docula.config.ts", "docula.config.mjs"]) {
1107
+ const configPath = path.join(sitePath, name);
1108
+ if (fs.existsSync(configPath)) return hashFile(hash, configPath);
1109
+ }
1110
+ return "";
1111
+ }
1044
1112
  function hashOptions(hash, options) {
1045
1113
  const relevant = {
1046
1114
  siteUrl: options.siteUrl,
@@ -1067,19 +1135,29 @@ function hashOptions(hash, options) {
1067
1135
  ai: options.ai,
1068
1136
  googleTagManager: options.googleTagManager
1069
1137
  };
1070
- return hash.toHashSync(JSON.stringify(relevant));
1138
+ const optionsHash = hash.toHashSync(JSON.stringify(relevant));
1139
+ const configHash = hashConfigFile(hash, options.sitePath);
1140
+ return hash.toHashSync(`${optionsHash}:${configHash}`);
1071
1141
  }
1072
1142
  function hashTemplateDirectory(hash, templatePath) {
1073
1143
  /* v8 ignore next 3 -- @preserve */
1074
1144
  if (!fs.existsSync(templatePath)) return "";
1075
- const hashes = listFilesRecursive(templatePath).map((f) => hashFile(hash, path.join(templatePath, f)));
1145
+ const files = listFilesRecursive(templatePath);
1146
+ const hashes = [];
1147
+ for (const f of files) {
1148
+ const fileHash = tryHashFile(hash, path.join(templatePath, f));
1149
+ if (fileHash !== void 0) hashes.push(fileHash);
1150
+ }
1076
1151
  return hash.toHashSync(hashes.join(""));
1077
1152
  }
1078
1153
  function hashSourceFiles(hash, dir) {
1079
1154
  const hashes = {};
1080
1155
  if (!fs.existsSync(dir)) return hashes;
1081
1156
  const files = listFilesRecursive(dir);
1082
- for (const file of files) hashes[file] = hashFile(hash, path.join(dir, file));
1157
+ for (const file of files) {
1158
+ const fileHash = tryHashFile(hash, path.join(dir, file));
1159
+ if (fileHash !== void 0) hashes[file] = fileHash;
1160
+ }
1083
1161
  return hashes;
1084
1162
  }
1085
1163
  function recordsEqual(a, b) {
@@ -1089,7 +1167,7 @@ function recordsEqual(a, b) {
1089
1167
  for (const key of keysA) if (a[key] !== b[key]) return false;
1090
1168
  return true;
1091
1169
  }
1092
- function hasAssetsChanged(hash, sitePath, previousAssets) {
1170
+ function hasAssetsChanged(hash, sitePath, previousAssets, autoReadme) {
1093
1171
  for (const file of [
1094
1172
  "favicon.ico",
1095
1173
  "logo.svg",
@@ -1104,6 +1182,14 @@ function hasAssetsChanged(hash, sitePath, previousAssets) {
1104
1182
  if (previousAssets[file] !== fileHash) return true;
1105
1183
  } else if (previousAssets[file]) return true;
1106
1184
  }
1185
+ const siteReadmeExists = fs.existsSync(path.join(sitePath, "README.md"));
1186
+ if (autoReadme === true && !siteReadmeExists || previousAssets.__autoReadme !== void 0) {
1187
+ const rootReadmePath = path.join(process.cwd(), "README.md");
1188
+ if (fs.existsSync(rootReadmePath)) {
1189
+ const currentRootHash = hashFile(hash, rootReadmePath);
1190
+ if (previousAssets.__autoReadme !== currentRootHash) return true;
1191
+ } else if (previousAssets.__autoReadme !== void 0) return true;
1192
+ }
1107
1193
  const publicPath = path.join(sitePath, "public");
1108
1194
  if (fs.existsSync(publicPath)) {
1109
1195
  const publicHashes = hashSourceFiles(hash, publicPath);
@@ -1254,6 +1340,10 @@ function parseChangelogEntry(filePath, options) {
1254
1340
  });
1255
1341
  const previewImage = matterData.previewImage;
1256
1342
  const draft = matterData.draft === true;
1343
+ const description = matterData.description || void 0;
1344
+ const keywords = Array.isArray(matterData.keywords) ? matterData.keywords : void 0;
1345
+ const ogTitle = matterData.ogTitle || void 0;
1346
+ const ogDescription = matterData.ogDescription || void 0;
1257
1347
  return {
1258
1348
  title: matterData.title ?? fileName,
1259
1349
  date: dateString,
@@ -1267,7 +1357,11 @@ function parseChangelogEntry(filePath, options) {
1267
1357
  draft,
1268
1358
  previewImage,
1269
1359
  urlPath: `${buildUrlPath(options.baseUrl, options.changelogPath, slug)}/index.html`,
1270
- lastModified: fs.statSync(filePath).mtime.toISOString().split("T")[0]
1360
+ lastModified: fs.statSync(filePath).mtime.toISOString().split("T")[0],
1361
+ description,
1362
+ keywords,
1363
+ ogTitle,
1364
+ ogDescription
1271
1365
  };
1272
1366
  }
1273
1367
  function generateChangelogPreview(markdown, maxLength = 500, mdx = false) {
@@ -2427,9 +2521,10 @@ var DoculaOptions = class {
2427
2521
  */
2428
2522
  autoUpdateIgnores = true;
2429
2523
  /**
2430
- * When true, automatically copies the project root README.md into the site
2431
- * directory if one does not already exist. The package.json name field is
2432
- * used to prepend a title heading when the README lacks one.
2524
+ * When true, automatically renders the project root README.md as the home
2525
+ * page if no README exists in the site directory. The README is read in
2526
+ * place and never copied into the site directory. The package.json name
2527
+ * field is used to prepend a title heading when the README lacks one.
2433
2528
  */
2434
2529
  autoReadme = true;
2435
2530
  /**
@@ -2670,6 +2765,7 @@ var DoculaBuilder = class {
2670
2765
  _console;
2671
2766
  _hash = new Hashery();
2672
2767
  onReleaseChangelog;
2768
+ onAutoReadme;
2673
2769
  get console() {
2674
2770
  return this._console;
2675
2771
  }
@@ -2697,14 +2793,16 @@ var DoculaBuilder = class {
2697
2793
  const currentChangelogHashes = hashSourceFiles(this._hash, `${this.options.sitePath}/changelog`);
2698
2794
  const currentAssetHashes = {};
2699
2795
  if (validManifest && fs.existsSync(this.options.output) && validManifest.templateHash === currentTemplateHash && recordsEqual(validManifest.docs, currentDocHashes) && recordsEqual(validManifest.changelog, currentChangelogHashes)) {
2700
- if (!hasAssetsChanged(this._hash, this.options.sitePath, validManifest.assets)) {
2796
+ if (!hasAssetsChanged(this._hash, this.options.sitePath, validManifest.assets, this.options.autoReadme)) {
2701
2797
  this._console.success("No changes detected, skipping build");
2702
2798
  return;
2703
2799
  }
2704
2800
  }
2705
2801
  const cachedDocs = validManifest ? loadCachedDocuments(this.options.sitePath) : /* @__PURE__ */ new Map();
2706
2802
  const cachedChangelog = validManifest ? loadCachedChangelog(this.options.sitePath) : /* @__PURE__ */ new Map();
2707
- await this.autoReadme();
2803
+ await fs.promises.mkdir(this.options.sitePath, { recursive: true });
2804
+ const autoReadmeResult = await this.autoReadme();
2805
+ const siteReadmeExists = !autoReadmeResult && fs.existsSync(path.join(this.options.sitePath, "README.md"));
2708
2806
  const doculaData = {
2709
2807
  siteUrl: this.options.siteUrl,
2710
2808
  siteTitle: this.options.siteTitle,
@@ -2714,7 +2812,8 @@ var DoculaBuilder = class {
2714
2812
  output: this.options.output,
2715
2813
  githubPath: this.options.githubPath,
2716
2814
  sections: this.options.sections,
2717
- hasReadme: fs.existsSync(`${this.options.sitePath}/README.md`),
2815
+ hasReadme: siteReadmeExists || autoReadmeResult !== void 0,
2816
+ readmeContent: autoReadmeResult?.content,
2718
2817
  themeMode: this.options.themeMode,
2719
2818
  cookieAuth: this.options.cookieAuth,
2720
2819
  headerLinks: this.options.headerLinks,
@@ -2732,8 +2831,8 @@ var DoculaBuilder = class {
2732
2831
  editPageUrl: this.options.editPageUrl,
2733
2832
  openGraph: this.options.openGraph
2734
2833
  };
2735
- const readmePath = `${this.options.sitePath}/README.md`;
2736
- if (doculaData.hasReadme) currentAssetHashes["README.md"] = hashFile(this._hash, readmePath);
2834
+ if (siteReadmeExists) currentAssetHashes["README.md"] = hashFile(this._hash, path.join(this.options.sitePath, "README.md"));
2835
+ else if (autoReadmeResult) currentAssetHashes.__autoReadme = hashFile(this._hash, autoReadmeResult.sourcePath);
2737
2836
  if (Array.isArray(this.options.openApiUrl)) doculaData.openApiSpecs = this.options.openApiUrl.map((spec) => ({
2738
2837
  name: spec.name,
2739
2838
  url: isRemoteUrl(spec.url) ? spec.url : buildUrlPath(this.options.apiPath, spec.url),
@@ -2797,7 +2896,7 @@ var DoculaBuilder = class {
2797
2896
  });
2798
2897
  doculaData.changelogEntries = allChangelogEntries;
2799
2898
  doculaData.hasChangelog = allChangelogEntries.length > 0 && hasChangelogTemplate;
2800
- /* v8 ignore next 19 -- @preserve */
2899
+ /* v8 ignore next 40 -- @preserve */
2801
2900
  if (this._options.ai) {
2802
2901
  const aiModel = await createAIModel(this._options.ai);
2803
2902
  if (aiModel) {
@@ -2805,6 +2904,7 @@ var DoculaBuilder = class {
2805
2904
  const aiCache = loadAIMetadataCache(this._options.sitePath);
2806
2905
  doculaData.documents = await enrichDocuments(doculaData.documents, aiModel, this._hash, this._console, aiCache);
2807
2906
  doculaData.changelogEntries = await enrichChangelogEntries(doculaData.changelogEntries, aiModel, this._hash, this._console, aiCache);
2907
+ if (doculaData.hasReadme && !doculaData.hasDocuments) doculaData.readmeMetadata = await enrichReadme(doculaData.readmeContent ?? (siteReadmeExists ? fs.readFileSync(`${this._options.sitePath}/README.md`, "utf8") : void 0), aiModel, this._hash, this._console, aiCache);
2808
2908
  saveAIMetadataCache(this._options.sitePath, aiCache);
2809
2909
  }
2810
2910
  }
@@ -2941,8 +3041,17 @@ var DoculaBuilder = class {
2941
3041
  if (packageJson.name && typeof packageJson.name === "string") readmeContent = `# ${packageJson.name}\n\n${readmeContent}`;
2942
3042
  } catch {}
2943
3043
  }
2944
- await fs.promises.mkdir(this._options.sitePath, { recursive: true });
2945
- await fs.promises.writeFile(siteReadmePath, readmeContent, "utf8");
3044
+ let content = readmeContent;
3045
+ if (this.onAutoReadme) try {
3046
+ content = await this.onAutoReadme(content, cwdReadmePath, this._console);
3047
+ } catch (error) {
3048
+ const message = error instanceof Error ? error.message : String(error);
3049
+ this._console.error(`onAutoReadme error: ${message}`);
3050
+ }
3051
+ return {
3052
+ sourcePath: cwdReadmePath,
3053
+ content
3054
+ };
2946
3055
  }
2947
3056
  async getGithubData(githubPath) {
2948
3057
  const paths = githubPath.split("/");
@@ -3016,11 +3125,14 @@ var DoculaBuilder = class {
3016
3125
  let content;
3017
3126
  if (!data.hasDocuments) content = await this.buildReadmeSection(data);
3018
3127
  const announcement = await this.buildAnnouncementSection(data);
3128
+ const readmeMeta = data.readmeMetadata;
3019
3129
  const indexContent = await this._ecto.renderFromFile(indexTemplate, {
3020
3130
  ...data,
3021
3131
  content,
3022
3132
  announcement,
3023
- ...this.resolveOpenGraphData(data, "/"),
3133
+ description: readmeMeta?.description ?? data.siteDescription,
3134
+ keywords: readmeMeta?.keywords,
3135
+ ...this.resolveOpenGraphData(data, "/", readmeMeta),
3024
3136
  jsonLd: this.resolveJsonLd("home", data, "/")
3025
3137
  }, data.templatePath);
3026
3138
  await fs.promises.writeFile(indexPath, indexContent, "utf8");
@@ -3051,7 +3163,8 @@ var DoculaBuilder = class {
3051
3163
  }
3052
3164
  async buildReadmeSection(data) {
3053
3165
  let htmlReadme = "";
3054
- if (fs.existsSync(`${data.sitePath}/README.md`)) htmlReadme = await new Writr$1(fs.readFileSync(`${data.sitePath}/README.md`, "utf8"), writrOptions).render();
3166
+ if (data.readmeContent !== void 0) htmlReadme = await new Writr$1(data.readmeContent.replace(/^\s*#\s+[^\r\n]*[\r\n]*/, ""), writrOptions).render();
3167
+ else if (fs.existsSync(`${data.sitePath}/README.md`)) htmlReadme = await new Writr$1(fs.readFileSync(`${data.sitePath}/README.md`, "utf8"), writrOptions).render();
3055
3168
  return htmlReadme;
3056
3169
  }
3057
3170
  async buildAnnouncementSection(data) {
@@ -3317,6 +3430,8 @@ var Docula = class {
3317
3430
  const builder = new DoculaBuilder(Object.assign(this.options, { console: this._console }));
3318
3431
  /* v8 ignore next 4 -- @preserve */
3319
3432
  if (this._configFileModule.onReleaseChangelog) builder.onReleaseChangelog = this._configFileModule.onReleaseChangelog;
3433
+ /* v8 ignore next 4 -- @preserve */
3434
+ if (this._configFileModule.onAutoReadme) builder.onAutoReadme = this._configFileModule.onAutoReadme;
3320
3435
  await builder.build();
3321
3436
  return builder;
3322
3437
  }
@@ -3417,12 +3532,16 @@ var Docula = class {
3417
3532
  }
3418
3533
  else {
3419
3534
  const { createJiti } = await import("jiti");
3420
- this._configFileModule = await createJiti(import.meta.url, { interopDefault: true }).import(absolutePath);
3535
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
3536
+ this._configFileModule = await jiti.import(absolutePath);
3421
3537
  }
3422
3538
  return;
3423
3539
  }
3424
3540
  /* v8 ignore next -- @preserve */
3425
- if (fs.existsSync(mjsConfigFile)) this._configFileModule = await import(pathToFileURL(path.resolve(mjsConfigFile)).href);
3541
+ if (fs.existsSync(mjsConfigFile)) {
3542
+ const absolutePath = path.resolve(mjsConfigFile);
3543
+ this._configFileModule = await import(pathToFileURL(absolutePath).href);
3544
+ }
3426
3545
  }
3427
3546
  /**
3428
3547
  * 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.12.0",
3
+ "version": "1.14.0",
4
4
  "description": "Beautiful Website for Your Projects",
5
5
  "type": "module",
6
6
  "main": "./dist/docula.js",
@@ -40,36 +40,33 @@
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.69",
44
+ "@ai-sdk/google": "^3.0.63",
45
+ "@ai-sdk/openai": "^3.0.53",
46
+ "@cacheable/net": "^2.0.7",
47
+ "ai": "^6.0.164",
48
48
  "colorette": "^2.0.20",
49
- "ecto": "^4.8.3",
50
- "feed": "^5.2.0",
51
- "hashery": "^1.5.1",
49
+ "ecto": "^4.8.4",
50
+ "hashery": "^2.0.0",
52
51
  "jiti": "^2.6.1",
53
52
  "serve-handler": "^6.1.7",
54
53
  "update-notifier": "^7.3.1",
55
- "writr": "^6.1.0"
54
+ "writr": "^6.1.1"
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.12",
58
+ "@playwright/test": "^1.59.1",
59
+ "@types/node": "^25.6.0",
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",
62
+ "@vitest/coverage-v8": "^4.1.4",
63
+ "dotenv": "^17.4.2",
67
64
  "postject": "1.0.0-alpha.6",
68
65
  "rimraf": "^6.1.3",
69
- "tsdown": "^0.21.7",
66
+ "tsdown": "^0.21.9",
70
67
  "tsx": "^4.21.0",
71
- "typescript": "^5.9.3",
72
- "vitest": "^4.1.0"
68
+ "typescript": "^6.0.2",
69
+ "vitest": "^4.1.4"
73
70
  },
74
71
  "files": [
75
72
  "dist",
@@ -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 */
@@ -68,12 +68,11 @@ body {
68
68
  gap: 12px;
69
69
  }
70
70
 
71
- .logo-link { display: flex; align-items: center; gap: 8px; text-decoration: none; color: var(--fg); }
71
+ .logo-link { display: flex !important; align-items: center !important; text-decoration: none !important; color: var(--fg) !important; font-size: 18px !important; font-weight: 600 !important; }
72
72
  .logo__img {
73
73
  height: 75px;
74
74
  width: auto;
75
75
  }
76
- .logo__text { font-size: 18px; font-weight: 600; }
77
76
 
78
77
  .theme-button {
79
78
  border: 1px solid transparent;
@@ -290,6 +289,44 @@ body {
290
289
  letter-spacing: 0.24px;
291
290
  }
292
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
+
293
330
  .nav-sidebar__list {
294
331
  margin-block: 2px;
295
332
  }
@@ -773,6 +810,36 @@ pre:hover .copy-code-btn { opacity: 1; }
773
810
  color: #92400e;
774
811
  }
775
812
 
813
+ .changelog-tag-added {
814
+ background-color: rgba(140, 220, 0, 0.15);
815
+ color: #4a7a00;
816
+ }
817
+
818
+ .changelog-tag-improved {
819
+ background-color: rgba(59, 130, 246, 0.15);
820
+ color: #1d4ed8;
821
+ }
822
+
823
+ .changelog-tag-fixed {
824
+ background-color: rgba(245, 158, 11, 0.15);
825
+ color: #b45309;
826
+ }
827
+
828
+ .changelog-tag-removed {
829
+ background-color: rgba(239, 68, 68, 0.15);
830
+ color: #b91c1c;
831
+ }
832
+
833
+ .changelog-tag-deprecated {
834
+ background-color: rgba(156, 163, 175, 0.15);
835
+ color: #4b5563;
836
+ }
837
+
838
+ .changelog-tag-security {
839
+ background-color: rgba(168, 85, 247, 0.15);
840
+ color: #6d28d9;
841
+ }
842
+
776
843
  .changelog-entry-title::after {
777
844
  content: "";
778
845
  position: absolute;
@@ -6,7 +6,6 @@
6
6
  </button>
7
7
  <a href="{{#if homeUrl}}{{homeUrl}}{{else}}{{baseUrl}}/{{/if}}" class="logo-link">
8
8
  <img alt="{{siteTitle}}" class="logo__img" src="{{baseUrl}}/logo.svg">
9
- <span class="logo__text">{{siteTitle}}</span>
10
9
  </a>
11
10
  <nav class="header-bottom__nav">
12
11
  {{#if hasDocuments}}
@@ -227,6 +227,48 @@
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 activeSidebarLink = document.querySelector('.nav-sidebar__item--active');
240
+ const activeSection = activeSidebarLink ? activeSidebarLink.closest('.nav-sidebar__section--collapsible') : null;
241
+ const defaultOpenSection = activeSection || collapsibleSections[0];
242
+
243
+ const setSectionOpen = (section, toggle, open) => {
244
+ toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
245
+ section.classList.toggle('nav-sidebar__section--collapsed', !open);
246
+ };
247
+
248
+ collapsibleSections.forEach((section, idx) => {
249
+ const toggle = section.querySelector('.nav-sidebar__toggle');
250
+ const list = section.querySelector('.nav-sidebar__list');
251
+ if (!toggle || !list) return;
252
+ const listId = 'nav-sidebar-section-' + idx;
253
+ list.id = listId;
254
+ toggle.setAttribute('aria-controls', listId);
255
+
256
+ const key = 'section-' + idx;
257
+ const hasStored = Object.prototype.hasOwnProperty.call(storedSectionState, key);
258
+ const isOpen = hasStored ? !!storedSectionState[key] : section === defaultOpenSection;
259
+ setSectionOpen(section, toggle, isOpen);
260
+
261
+ toggle.addEventListener('click', () => {
262
+ const next = toggle.getAttribute('aria-expanded') !== 'true';
263
+ setSectionOpen(section, toggle, next);
264
+ storedSectionState[key] = next;
265
+ try {
266
+ localStorage.setItem(SIDEBAR_STORAGE_KEY, JSON.stringify(storedSectionState));
267
+ } catch (e) { /* storage unavailable */ }
268
+ });
269
+ });
270
+ }
271
+
230
272
  // Active header nav link highlighting
231
273
  const navLinks = document.querySelectorAll('.header-bottom__item');
232
274
  navLinks.forEach((link) => {
@@ -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>