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 +22 -5
- package/dist/docula.js +175 -56
- package/package.json +17 -20
- package/templates/modern/api.hbs +1 -1
- package/templates/modern/css/api.css +22 -4
- package/templates/modern/css/styles.css +69 -2
- package/templates/modern/includes/header-bar.hbs +0 -1
- package/templates/modern/includes/scripts.hbs +42 -0
- package/templates/modern/includes/sidebar.hbs +7 -2
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
|
|
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
|
|
349
|
-
*
|
|
350
|
-
*
|
|
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<
|
|
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.
|
|
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.
|
|
73
|
-
"@ai-sdk/google": "^3.0.
|
|
74
|
-
"@ai-sdk/openai": "^3.0.
|
|
75
|
-
"@cacheable/net": "^2.0.
|
|
76
|
-
"ai": "^6.0.
|
|
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.
|
|
79
|
-
"
|
|
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.
|
|
83
|
+
"writr": "^6.1.1"
|
|
85
84
|
},
|
|
86
85
|
devDependencies: {
|
|
87
|
-
"@biomejs/biome": "^2.4.
|
|
88
|
-
"@playwright/test": "^1.
|
|
89
|
-
"@types/
|
|
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.
|
|
95
|
-
"dotenv": "^17.
|
|
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.
|
|
95
|
+
"tsdown": "^0.21.9",
|
|
99
96
|
"tsx": "^4.21.0",
|
|
100
|
-
"typescript": "^
|
|
101
|
-
"vitest": "^4.1.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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 ??
|
|
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
|
-
|
|
284
|
-
|
|
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(
|
|
382
|
+
id: slugify(operationId),
|
|
333
383
|
method,
|
|
334
|
-
methodUpper
|
|
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?.
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
2431
|
-
*
|
|
2432
|
-
*
|
|
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.
|
|
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:
|
|
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
|
-
|
|
2736
|
-
if (
|
|
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
|
|
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
|
-
|
|
2945
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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))
|
|
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.
|
|
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.
|
|
44
|
-
"@ai-sdk/google": "^3.0.
|
|
45
|
-
"@ai-sdk/openai": "^3.0.
|
|
46
|
-
"@cacheable/net": "^2.0.
|
|
47
|
-
"ai": "^6.0.
|
|
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.
|
|
50
|
-
"
|
|
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.
|
|
54
|
+
"writr": "^6.1.1"
|
|
56
55
|
},
|
|
57
56
|
"devDependencies": {
|
|
58
|
-
"@biomejs/biome": "^2.4.
|
|
59
|
-
"@playwright/test": "^1.
|
|
60
|
-
"@types/
|
|
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.
|
|
66
|
-
"dotenv": "^17.
|
|
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.
|
|
66
|
+
"tsdown": "^0.21.9",
|
|
70
67
|
"tsx": "^4.21.0",
|
|
71
|
-
"typescript": "^
|
|
72
|
-
"vitest": "^4.1.
|
|
68
|
+
"typescript": "^6.0.2",
|
|
69
|
+
"vitest": "^4.1.4"
|
|
73
70
|
},
|
|
74
71
|
"files": [
|
|
75
72
|
"dist",
|
package/templates/modern/api.hbs
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
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">
|
|
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>
|