docula 1.12.0 → 1.13.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
@@ -153,6 +153,10 @@ type DoculaChangelogEntry = {
153
153
  previewImage?: string;
154
154
  urlPath: string;
155
155
  lastModified: string;
156
+ description?: string;
157
+ keywords?: string[];
158
+ ogTitle?: string;
159
+ ogDescription?: string;
156
160
  };
157
161
  type DoculaData = {
158
162
  siteUrl: string;
@@ -175,6 +179,13 @@ type DoculaData = {
175
179
  openApiSpecs?: DoculaOpenApiSpecEntry[];
176
180
  changelogEntries?: DoculaChangelogEntry[];
177
181
  hasReadme?: boolean;
182
+ readmeContent?: string;
183
+ readmeMetadata?: {
184
+ description?: string;
185
+ keywords?: string[];
186
+ ogTitle?: string;
187
+ ogDescription?: string;
188
+ };
178
189
  themeMode?: string;
179
190
  cookieAuth?: {
180
191
  loginUrl: string;
@@ -271,7 +282,7 @@ type DoculaCacheOptions = {
271
282
  type DoculaAIOptions = {
272
283
  provider: string;
273
284
  model?: string;
274
- apiKey: string;
285
+ apiKey?: string;
275
286
  };
276
287
  declare class DoculaOptions {
277
288
  /**
@@ -345,9 +356,10 @@ declare class DoculaOptions {
345
356
  */
346
357
  autoUpdateIgnores: boolean;
347
358
  /**
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.
359
+ * When true, automatically renders the project root README.md as the home
360
+ * page if no README exists in the site directory. The README is read in
361
+ * place and never copied into the site directory. The package.json name
362
+ * field is used to prepend a title heading when the README lacks one.
351
363
  */
352
364
  autoReadme: boolean;
353
365
  /**
@@ -441,7 +453,10 @@ declare class DoculaBuilder {
441
453
  get options(): DoculaOptions;
442
454
  build(): Promise<void>;
443
455
  validateOptions(options: DoculaOptions): void;
444
- autoReadme(): Promise<void>;
456
+ autoReadme(): Promise<{
457
+ sourcePath: string;
458
+ content: string;
459
+ } | undefined>;
445
460
  getGithubData(githubPath: string): Promise<GithubData>;
446
461
  getTemplates(templatePath: string, hasDocuments: boolean, hasChangelog?: boolean): Promise<DoculaTemplates>;
447
462
  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.13.0";
16
16
  var package_default = {
17
17
  name: "docula",
18
18
  version,
@@ -161,13 +161,13 @@ function saveAIMetadataCache(sitePath, cache) {
161
161
  * Check if a document needs AI enrichment for OG/meta fields.
162
162
  */
163
163
  function needsDocumentEnrichment(doc) {
164
- return !doc.description || doc.keywords.length === 0 || !doc.ogTitle || !doc.ogDescription;
164
+ return !doc.description || doc.keywords.length === 0 || !doc.ogDescription;
165
165
  }
166
166
  /**
167
167
  * Check if a changelog entry needs AI enrichment.
168
168
  */
169
169
  function needsChangelogEnrichment(entry) {
170
- return !entry.title || !entry.preview;
170
+ return !entry.preview || !entry.description || !entry.keywords?.length || !entry.ogDescription;
171
171
  }
172
172
  /**
173
173
  * Enrich documents with AI-generated metadata for OG/meta tags.
@@ -183,9 +183,13 @@ async function enrichDocuments(documents, model, hash, console, cache) {
183
183
  const bodyHash = hash.toHashSync(doc.content);
184
184
  const cached = cache[bodyHash];
185
185
  if (cached) {
186
- enriched[i] = applyMetadataToDocument(doc, cached);
187
- logDocumentMetadata(console, doc.title || doc.documentPath, cached, true);
188
- continue;
186
+ const applied = applyMetadataToDocument(doc, cached);
187
+ if (!needsDocumentEnrichment(applied)) {
188
+ enriched[i] = applied;
189
+ logDocumentMetadata(console, doc.title || doc.documentPath, cached, true);
190
+ continue;
191
+ }
192
+ delete cache[bodyHash];
189
193
  }
190
194
  if (doc.content.trim().length < 10) continue;
191
195
  const metadata = await new Writr$1(doc.content, {
@@ -210,14 +214,19 @@ async function enrichChangelogEntries(entries, model, hash, console, cache) {
210
214
  const enriched = [...entries];
211
215
  for (let i = 0; i < enriched.length; i++) {
212
216
  const entry = enriched[i];
217
+ /* v8 ignore next -- @preserve */
213
218
  if (!needsChangelogEnrichment(entry)) continue;
214
219
  try {
215
220
  const bodyHash = hash.toHashSync(entry.content);
216
221
  const cached = cache[bodyHash];
217
222
  if (cached) {
218
- enriched[i] = applyMetadataToChangelog(entry, cached);
219
- logChangelogMetadata(console, entry.title || entry.slug, cached, true);
220
- continue;
223
+ const applied = applyMetadataToChangelog(entry, cached);
224
+ if (!needsChangelogEnrichment(applied)) {
225
+ enriched[i] = applied;
226
+ logChangelogMetadata(console, entry.title || entry.slug, cached, true);
227
+ continue;
228
+ }
229
+ delete cache[bodyHash];
221
230
  }
222
231
  if (entry.content.trim().length < 10) continue;
223
232
  const metadata = await new Writr$1(entry.content, {
@@ -235,6 +244,45 @@ async function enrichChangelogEntries(entries, model, hash, console, cache) {
235
244
  return enriched;
236
245
  }
237
246
  /**
247
+ * Enrich the site README with AI-generated metadata for OG/meta tags.
248
+ * Accepts the README content directly (from doculaData.readmeContent or
249
+ * by reading sitePath/README.md). Returns mapped metadata or undefined
250
+ * if content is missing, too small, or enrichment fails.
251
+ */
252
+ async function enrichReadme(content, model, hash, console, cache) {
253
+ if (!content) return;
254
+ try {
255
+ if (content.trim().length < 10) return;
256
+ const bodyHash = hash.toHashSync(content);
257
+ const cached = cache[bodyHash];
258
+ if (cached) {
259
+ logDocumentMetadata(console, "README", cached, true);
260
+ return {
261
+ description: cached.description,
262
+ keywords: cached.keywords,
263
+ ogTitle: cached.title,
264
+ ogDescription: cached.description
265
+ };
266
+ }
267
+ const metadata = await new Writr$1(content, {
268
+ ...writrOptions$6,
269
+ ai: { model }
270
+ }).ai?.getMetadata();
271
+ if (!metadata) return;
272
+ cache[bodyHash] = metadata;
273
+ logDocumentMetadata(console, "README", metadata, false);
274
+ return {
275
+ description: metadata.description,
276
+ keywords: metadata.keywords,
277
+ ogTitle: metadata.title,
278
+ ogDescription: metadata.description
279
+ };
280
+ } catch (error) {
281
+ console.warn(`AI enrichment failed for README: ${error.message}`);
282
+ return;
283
+ }
284
+ }
285
+ /**
238
286
  * Log AI-generated metadata for a document.
239
287
  */
240
288
  function truncate(value, max = 60) {
@@ -248,7 +296,6 @@ function logDocumentMetadata(console, name, metadata, fromCache) {
248
296
  console.info(`AI enriched: ${name}`);
249
297
  if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
250
298
  if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
251
- if (metadata.title) console.log(white(` ogTitle: ${truncate(metadata.title)}`));
252
299
  }
253
300
  /**
254
301
  * Log AI-generated metadata for a changelog entry.
@@ -259,8 +306,9 @@ function logChangelogMetadata(console, name, metadata, fromCache) {
259
306
  return;
260
307
  }
261
308
  console.info(`AI enriched changelog: ${name}`);
262
- if (metadata.title) console.log(white(` title: ${truncate(metadata.title)}`));
263
309
  if (metadata.preview || metadata.summary) console.log(white(` preview: ${truncate(metadata.preview || metadata.summary || "")}`));
310
+ if (metadata.description) console.log(white(` description: ${truncate(metadata.description)}`));
311
+ if (metadata.keywords?.length) console.log(white(` keywords: ${truncate(metadata.keywords.join(", "))}`));
264
312
  }
265
313
  /**
266
314
  * Apply AI-generated metadata to a document, filling only missing fields.
@@ -270,7 +318,7 @@ function applyMetadataToDocument(doc, metadata) {
270
318
  ...doc,
271
319
  description: doc.description || metadata.description || "",
272
320
  keywords: doc.keywords.length > 0 ? doc.keywords : metadata.keywords ?? [],
273
- ogTitle: doc.ogTitle ?? metadata.title,
321
+ ogTitle: doc.ogTitle ?? (doc.title || void 0),
274
322
  ogDescription: doc.ogDescription ?? metadata.description
275
323
  };
276
324
  }
@@ -280,8 +328,11 @@ function applyMetadataToDocument(doc, metadata) {
280
328
  function applyMetadataToChangelog(entry, metadata) {
281
329
  return {
282
330
  ...entry,
283
- title: entry.title || metadata.title || "",
284
- preview: entry.preview || metadata.preview || metadata.summary || ""
331
+ preview: entry.preview || metadata.preview || metadata.summary || "",
332
+ description: entry.description || metadata.description || void 0,
333
+ keywords: entry.keywords?.length ? entry.keywords : metadata.keywords ?? [],
334
+ ogTitle: entry.ogTitle ?? (entry.title || void 0),
335
+ ogDescription: entry.ogDescription ?? metadata.description
285
336
  };
286
337
  }
287
338
  //#endregion
@@ -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 */
@@ -1089,7 +1141,7 @@ function recordsEqual(a, b) {
1089
1141
  for (const key of keysA) if (a[key] !== b[key]) return false;
1090
1142
  return true;
1091
1143
  }
1092
- function hasAssetsChanged(hash, sitePath, previousAssets) {
1144
+ function hasAssetsChanged(hash, sitePath, previousAssets, autoReadme) {
1093
1145
  for (const file of [
1094
1146
  "favicon.ico",
1095
1147
  "logo.svg",
@@ -1104,6 +1156,14 @@ function hasAssetsChanged(hash, sitePath, previousAssets) {
1104
1156
  if (previousAssets[file] !== fileHash) return true;
1105
1157
  } else if (previousAssets[file]) return true;
1106
1158
  }
1159
+ const siteReadmeExists = fs.existsSync(path.join(sitePath, "README.md"));
1160
+ if (autoReadme === true && !siteReadmeExists || previousAssets.__autoReadme !== void 0) {
1161
+ const rootReadmePath = path.join(process.cwd(), "README.md");
1162
+ if (fs.existsSync(rootReadmePath)) {
1163
+ const currentRootHash = hashFile(hash, rootReadmePath);
1164
+ if (previousAssets.__autoReadme !== currentRootHash) return true;
1165
+ } else if (previousAssets.__autoReadme !== void 0) return true;
1166
+ }
1107
1167
  const publicPath = path.join(sitePath, "public");
1108
1168
  if (fs.existsSync(publicPath)) {
1109
1169
  const publicHashes = hashSourceFiles(hash, publicPath);
@@ -1254,6 +1314,10 @@ function parseChangelogEntry(filePath, options) {
1254
1314
  });
1255
1315
  const previewImage = matterData.previewImage;
1256
1316
  const draft = matterData.draft === true;
1317
+ const description = matterData.description || void 0;
1318
+ const keywords = Array.isArray(matterData.keywords) ? matterData.keywords : void 0;
1319
+ const ogTitle = matterData.ogTitle || void 0;
1320
+ const ogDescription = matterData.ogDescription || void 0;
1257
1321
  return {
1258
1322
  title: matterData.title ?? fileName,
1259
1323
  date: dateString,
@@ -1267,7 +1331,11 @@ function parseChangelogEntry(filePath, options) {
1267
1331
  draft,
1268
1332
  previewImage,
1269
1333
  urlPath: `${buildUrlPath(options.baseUrl, options.changelogPath, slug)}/index.html`,
1270
- lastModified: fs.statSync(filePath).mtime.toISOString().split("T")[0]
1334
+ lastModified: fs.statSync(filePath).mtime.toISOString().split("T")[0],
1335
+ description,
1336
+ keywords,
1337
+ ogTitle,
1338
+ ogDescription
1271
1339
  };
1272
1340
  }
1273
1341
  function generateChangelogPreview(markdown, maxLength = 500, mdx = false) {
@@ -2427,9 +2495,10 @@ var DoculaOptions = class {
2427
2495
  */
2428
2496
  autoUpdateIgnores = true;
2429
2497
  /**
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.
2498
+ * When true, automatically renders the project root README.md as the home
2499
+ * page if no README exists in the site directory. The README is read in
2500
+ * place and never copied into the site directory. The package.json name
2501
+ * field is used to prepend a title heading when the README lacks one.
2433
2502
  */
2434
2503
  autoReadme = true;
2435
2504
  /**
@@ -2697,14 +2766,16 @@ var DoculaBuilder = class {
2697
2766
  const currentChangelogHashes = hashSourceFiles(this._hash, `${this.options.sitePath}/changelog`);
2698
2767
  const currentAssetHashes = {};
2699
2768
  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)) {
2769
+ if (!hasAssetsChanged(this._hash, this.options.sitePath, validManifest.assets, this.options.autoReadme)) {
2701
2770
  this._console.success("No changes detected, skipping build");
2702
2771
  return;
2703
2772
  }
2704
2773
  }
2705
2774
  const cachedDocs = validManifest ? loadCachedDocuments(this.options.sitePath) : /* @__PURE__ */ new Map();
2706
2775
  const cachedChangelog = validManifest ? loadCachedChangelog(this.options.sitePath) : /* @__PURE__ */ new Map();
2707
- await this.autoReadme();
2776
+ await fs.promises.mkdir(this.options.sitePath, { recursive: true });
2777
+ const autoReadmeResult = await this.autoReadme();
2778
+ const siteReadmeExists = !autoReadmeResult && fs.existsSync(path.join(this.options.sitePath, "README.md"));
2708
2779
  const doculaData = {
2709
2780
  siteUrl: this.options.siteUrl,
2710
2781
  siteTitle: this.options.siteTitle,
@@ -2714,7 +2785,8 @@ var DoculaBuilder = class {
2714
2785
  output: this.options.output,
2715
2786
  githubPath: this.options.githubPath,
2716
2787
  sections: this.options.sections,
2717
- hasReadme: fs.existsSync(`${this.options.sitePath}/README.md`),
2788
+ hasReadme: siteReadmeExists || autoReadmeResult !== void 0,
2789
+ readmeContent: autoReadmeResult?.content,
2718
2790
  themeMode: this.options.themeMode,
2719
2791
  cookieAuth: this.options.cookieAuth,
2720
2792
  headerLinks: this.options.headerLinks,
@@ -2732,8 +2804,8 @@ var DoculaBuilder = class {
2732
2804
  editPageUrl: this.options.editPageUrl,
2733
2805
  openGraph: this.options.openGraph
2734
2806
  };
2735
- const readmePath = `${this.options.sitePath}/README.md`;
2736
- if (doculaData.hasReadme) currentAssetHashes["README.md"] = hashFile(this._hash, readmePath);
2807
+ if (siteReadmeExists) currentAssetHashes["README.md"] = hashFile(this._hash, path.join(this.options.sitePath, "README.md"));
2808
+ else if (autoReadmeResult) currentAssetHashes.__autoReadme = hashFile(this._hash, autoReadmeResult.sourcePath);
2737
2809
  if (Array.isArray(this.options.openApiUrl)) doculaData.openApiSpecs = this.options.openApiUrl.map((spec) => ({
2738
2810
  name: spec.name,
2739
2811
  url: isRemoteUrl(spec.url) ? spec.url : buildUrlPath(this.options.apiPath, spec.url),
@@ -2797,7 +2869,7 @@ var DoculaBuilder = class {
2797
2869
  });
2798
2870
  doculaData.changelogEntries = allChangelogEntries;
2799
2871
  doculaData.hasChangelog = allChangelogEntries.length > 0 && hasChangelogTemplate;
2800
- /* v8 ignore next 19 -- @preserve */
2872
+ /* v8 ignore next 40 -- @preserve */
2801
2873
  if (this._options.ai) {
2802
2874
  const aiModel = await createAIModel(this._options.ai);
2803
2875
  if (aiModel) {
@@ -2805,6 +2877,7 @@ var DoculaBuilder = class {
2805
2877
  const aiCache = loadAIMetadataCache(this._options.sitePath);
2806
2878
  doculaData.documents = await enrichDocuments(doculaData.documents, aiModel, this._hash, this._console, aiCache);
2807
2879
  doculaData.changelogEntries = await enrichChangelogEntries(doculaData.changelogEntries, aiModel, this._hash, this._console, aiCache);
2880
+ 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
2881
  saveAIMetadataCache(this._options.sitePath, aiCache);
2809
2882
  }
2810
2883
  }
@@ -2941,8 +3014,10 @@ var DoculaBuilder = class {
2941
3014
  if (packageJson.name && typeof packageJson.name === "string") readmeContent = `# ${packageJson.name}\n\n${readmeContent}`;
2942
3015
  } catch {}
2943
3016
  }
2944
- await fs.promises.mkdir(this._options.sitePath, { recursive: true });
2945
- await fs.promises.writeFile(siteReadmePath, readmeContent, "utf8");
3017
+ return {
3018
+ sourcePath: cwdReadmePath,
3019
+ content: readmeContent
3020
+ };
2946
3021
  }
2947
3022
  async getGithubData(githubPath) {
2948
3023
  const paths = githubPath.split("/");
@@ -3016,11 +3091,14 @@ var DoculaBuilder = class {
3016
3091
  let content;
3017
3092
  if (!data.hasDocuments) content = await this.buildReadmeSection(data);
3018
3093
  const announcement = await this.buildAnnouncementSection(data);
3094
+ const readmeMeta = data.readmeMetadata;
3019
3095
  const indexContent = await this._ecto.renderFromFile(indexTemplate, {
3020
3096
  ...data,
3021
3097
  content,
3022
3098
  announcement,
3023
- ...this.resolveOpenGraphData(data, "/"),
3099
+ description: readmeMeta?.description ?? data.siteDescription,
3100
+ keywords: readmeMeta?.keywords,
3101
+ ...this.resolveOpenGraphData(data, "/", readmeMeta),
3024
3102
  jsonLd: this.resolveJsonLd("home", data, "/")
3025
3103
  }, data.templatePath);
3026
3104
  await fs.promises.writeFile(indexPath, indexContent, "utf8");
@@ -3051,7 +3129,8 @@ var DoculaBuilder = class {
3051
3129
  }
3052
3130
  async buildReadmeSection(data) {
3053
3131
  let htmlReadme = "";
3054
- if (fs.existsSync(`${data.sitePath}/README.md`)) htmlReadme = await new Writr$1(fs.readFileSync(`${data.sitePath}/README.md`, "utf8"), writrOptions).render();
3132
+ if (data.readmeContent !== void 0) htmlReadme = await new Writr$1(data.readmeContent.replace(/^\s*#\s+[^\r\n]*[\r\n]*/, ""), writrOptions).render();
3133
+ else if (fs.existsSync(`${data.sitePath}/README.md`)) htmlReadme = await new Writr$1(fs.readFileSync(`${data.sitePath}/README.md`, "utf8"), writrOptions).render();
3055
3134
  return htmlReadme;
3056
3135
  }
3057
3136
  async buildAnnouncementSection(data) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docula",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "Beautiful Website for Your Projects",
5
5
  "type": "module",
6
6
  "main": "./dist/docula.js",
@@ -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;
@@ -773,6 +772,36 @@ pre:hover .copy-code-btn { opacity: 1; }
773
772
  color: #92400e;
774
773
  }
775
774
 
775
+ .changelog-tag-added {
776
+ background-color: rgba(140, 220, 0, 0.15);
777
+ color: #4a7a00;
778
+ }
779
+
780
+ .changelog-tag-improved {
781
+ background-color: rgba(59, 130, 246, 0.15);
782
+ color: #1d4ed8;
783
+ }
784
+
785
+ .changelog-tag-fixed {
786
+ background-color: rgba(245, 158, 11, 0.15);
787
+ color: #b45309;
788
+ }
789
+
790
+ .changelog-tag-removed {
791
+ background-color: rgba(239, 68, 68, 0.15);
792
+ color: #b91c1c;
793
+ }
794
+
795
+ .changelog-tag-deprecated {
796
+ background-color: rgba(156, 163, 175, 0.15);
797
+ color: #4b5563;
798
+ }
799
+
800
+ .changelog-tag-security {
801
+ background-color: rgba(168, 85, 247, 0.15);
802
+ color: #6d28d9;
803
+ }
804
+
776
805
  .changelog-entry-title::after {
777
806
  content: "";
778
807
  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}}