docula 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,7 +36,7 @@
36
36
  * For more complex projects easily add a `docula.config.ts` (TypeScript) or `docula.config.mjs` (JavaScript) file to customize the build process with lifecycle hooks.
37
37
  * Full TypeScript support with typed configuration and IDE autocompletion.
38
38
  * Support for single page with readme or multiple markdown pages in a docs folder.
39
- * Will generate a sitemap.xml and robots.txt for your site.
39
+ * Will generate a sitemap.xml, robots.txt, and `feed.xml` for your site.
40
40
  * Automatically generates `llms.txt` and `llms-full.txt` for LLM-friendly indexing of docs, API reference, and changelog content.
41
41
  * Uses Github release notes to generate a changelog / releases page.
42
42
  * Uses Github to show contributors and link to their profiles.
@@ -558,6 +558,15 @@ When changelog entries are found, Docula generates:
558
558
 
559
559
  Changelog URLs are also automatically added to the generated `sitemap.xml`.
560
560
 
561
+ ## RSS Feed
562
+
563
+ Docula automatically generates a docs-only RSS 2.0 feed at `/feed.xml` when your site has documentation pages.
564
+
565
+ - The feed includes one item per generated doc page.
566
+ - Each item uses the document title and canonical page URL.
567
+ - Item descriptions prefer front matter descriptions and otherwise fall back to a short excerpt from the markdown body.
568
+ - `/feed.xml` is added to `sitemap.xml` for discovery.
569
+
561
570
  ## Styling
562
571
 
563
572
  Tags receive a CSS class based on their value (e.g., a tag of `"Bug Fix"` gets the class `changelog-tag-bug-fix`). You can style tags and other changelog elements by overriding these classes in your `variables.css`:
package/dist/docula.d.ts CHANGED
@@ -52,6 +52,27 @@ type ApiGroup = {
52
52
  id: string;
53
53
  operations: ApiOperation[];
54
54
  };
55
+ type ApiOAuth2Flow = {
56
+ authorizationUrl?: string;
57
+ tokenUrl?: string;
58
+ refreshUrl?: string;
59
+ scopes: Record<string, string>;
60
+ };
61
+ type ApiSecurityScheme = {
62
+ key: string;
63
+ type: string;
64
+ scheme?: string;
65
+ bearerFormat?: string;
66
+ name?: string;
67
+ in?: string;
68
+ description: string;
69
+ flows?: {
70
+ authorizationCode?: ApiOAuth2Flow;
71
+ implicit?: ApiOAuth2Flow;
72
+ clientCredentials?: ApiOAuth2Flow;
73
+ password?: ApiOAuth2Flow;
74
+ };
75
+ };
55
76
  type ApiSpecData = {
56
77
  info: {
57
78
  title: string;
@@ -63,6 +84,7 @@ type ApiSpecData = {
63
84
  description: string;
64
85
  }>;
65
86
  groups: ApiGroup[];
87
+ securitySchemes: ApiSecurityScheme[];
66
88
  };
67
89
 
68
90
  type GithubData = {
@@ -75,6 +97,11 @@ type DoculaCookieAuth = {
75
97
  cookieName?: string;
76
98
  logoutUrl?: string;
77
99
  };
100
+ type DoculaHeaderLink = {
101
+ label: string;
102
+ url: string;
103
+ icon?: string;
104
+ };
78
105
  type DoculaCacheOptions = {
79
106
  github: {
80
107
  ttl: number;
@@ -161,6 +188,11 @@ declare class DoculaOptions {
161
188
  * in the header based on whether a JWT cookie is present.
162
189
  */
163
190
  cookieAuth?: DoculaCookieAuth;
191
+ /**
192
+ * Additional links to display in the site header navigation.
193
+ * Each link requires a label and url.
194
+ */
195
+ headerLinks?: DoculaHeaderLink[];
164
196
  /**
165
197
  * File extensions to copy as assets from docs/ and changelog/ directories.
166
198
  * Override in docula.config to customize.
@@ -217,6 +249,13 @@ type DoculaData = {
217
249
  cookieName?: string;
218
250
  logoutUrl?: string;
219
251
  };
252
+ headerLinks?: Array<{
253
+ label: string;
254
+ url: string;
255
+ icon?: string;
256
+ }>;
257
+ enableLlmsTxt?: boolean;
258
+ hasFeed?: boolean;
220
259
  };
221
260
  type DoculaTemplates = {
222
261
  home: string;
@@ -258,11 +297,14 @@ declare class DoculaBuilder {
258
297
  getTemplateFile(path: string, name: string): Promise<string | undefined>;
259
298
  buildRobotsPage(options: DoculaOptions): Promise<void>;
260
299
  buildSiteMapPage(data: DoculaData): Promise<void>;
300
+ buildFeedPage(data: DoculaData): Promise<void>;
261
301
  buildLlmsFiles(data: DoculaData): Promise<void>;
262
302
  private generateLlmsIndexContent;
263
303
  private generateLlmsFullContent;
264
304
  private buildAbsoluteSiteUrl;
265
305
  private normalizePathForUrl;
306
+ private escapeXml;
307
+ private summarizeMarkdown;
266
308
  private isRemoteUrl;
267
309
  private resolveOpenApiSpecUrl;
268
310
  private resolveLocalOpenApiPath;
@@ -392,4 +434,4 @@ declare class Docula {
392
434
  serve(options: DoculaOptions): Promise<http.Server>;
393
435
  }
394
436
 
395
- export { type DoculaCacheOptions, type DoculaCookieAuth, DoculaOptions, Docula as default };
437
+ export { type DoculaCacheOptions, type DoculaCookieAuth, type DoculaHeaderLink, DoculaOptions, Docula as default };
package/dist/docula.js CHANGED
@@ -100,7 +100,43 @@ function parseOpenApiSpec(specJson) {
100
100
  operations
101
101
  });
102
102
  }
103
- return { info, servers, groups };
103
+ const securitySchemes = [];
104
+ const schemesObj = spec.components?.securitySchemes;
105
+ if (schemesObj && typeof schemesObj === "object") {
106
+ for (const [key, value] of Object.entries(schemesObj)) {
107
+ const scheme = value;
108
+ const entry = {
109
+ key,
110
+ type: scheme.type ?? "",
111
+ scheme: scheme.scheme,
112
+ bearerFormat: scheme.bearerFormat,
113
+ name: scheme.name,
114
+ in: scheme.in,
115
+ description: scheme.description ?? ""
116
+ };
117
+ if (scheme.type === "oauth2" && scheme.flows) {
118
+ entry.flows = {};
119
+ for (const flowType of [
120
+ "authorizationCode",
121
+ "implicit",
122
+ "clientCredentials",
123
+ "password"
124
+ ]) {
125
+ const flow = scheme.flows[flowType];
126
+ if (flow) {
127
+ entry.flows[flowType] = {
128
+ authorizationUrl: flow.authorizationUrl,
129
+ tokenUrl: flow.tokenUrl,
130
+ refreshUrl: flow.refreshUrl,
131
+ scopes: flow.scopes ?? {}
132
+ };
133
+ }
134
+ }
135
+ }
136
+ securitySchemes.push(entry);
137
+ }
138
+ }
139
+ return { info, servers, groups, securitySchemes };
104
140
  }
105
141
  function getStatusClass(statusCode) {
106
142
  if (statusCode.startsWith("2")) {
@@ -200,7 +236,7 @@ function getSchemaType(schema) {
200
236
  if (schema.type === "array") {
201
237
  const items = schema.items;
202
238
  const itemType = items?.type ?? "any";
203
- return `array<${itemType}>`;
239
+ return `array(${itemType})`;
204
240
  }
205
241
  if (schema.type) {
206
242
  if (schema.format) {
@@ -920,6 +956,11 @@ var DoculaOptions = class {
920
956
  * in the header based on whether a JWT cookie is present.
921
957
  */
922
958
  cookieAuth;
959
+ /**
960
+ * Additional links to display in the site header navigation.
961
+ * Each link requires a label and url.
962
+ */
963
+ headerLinks;
923
964
  /**
924
965
  * File extensions to copy as assets from docs/ and changelog/ directories.
925
966
  * Override in docula.config to customize.
@@ -1027,6 +1068,14 @@ var DoculaOptions = class {
1027
1068
  if (options.cookieAuth && typeof options.cookieAuth === "object" && typeof options.cookieAuth.loginUrl === "string") {
1028
1069
  this.cookieAuth = options.cookieAuth;
1029
1070
  }
1071
+ if (options.headerLinks && Array.isArray(options.headerLinks)) {
1072
+ const validLinks = options.headerLinks.filter(
1073
+ (link) => typeof link === "object" && link !== null && typeof link.label === "string" && typeof link.url === "string"
1074
+ );
1075
+ if (validLinks.length > 0) {
1076
+ this.headerLinks = validLinks;
1077
+ }
1078
+ }
1030
1079
  }
1031
1080
  };
1032
1081
 
@@ -1094,7 +1143,9 @@ var DoculaBuilder = class {
1094
1143
  openApiUrl: this.options.openApiUrl,
1095
1144
  homePage: this.options.homePage,
1096
1145
  themeMode: this.options.themeMode,
1097
- cookieAuth: this.options.cookieAuth
1146
+ cookieAuth: this.options.cookieAuth,
1147
+ headerLinks: this.options.headerLinks,
1148
+ enableLlmsTxt: this.options.enableLlmsTxt
1098
1149
  };
1099
1150
  if (!doculaData.openApiUrl && fs3.existsSync(`${doculaData.sitePath}/api/swagger.json`)) {
1100
1151
  doculaData.openApiUrl = "/api/swagger.json";
@@ -1111,6 +1162,7 @@ var DoculaBuilder = class {
1111
1162
  this.options
1112
1163
  );
1113
1164
  doculaData.hasDocuments = doculaData.documents?.length > 0;
1165
+ doculaData.hasFeed = doculaData.hasDocuments;
1114
1166
  const changelogPath = `${doculaData.sitePath}/changelog`;
1115
1167
  const fileChangelogEntries = this.getChangelogEntries(changelogPath);
1116
1168
  const hasChangelogTemplate = await this.getTemplateFile(resolvedTemplatePath, "changelog") !== void 0;
@@ -1162,6 +1214,10 @@ var DoculaBuilder = class {
1162
1214
  this._console.fileBuilt("sitemap.xml");
1163
1215
  await this.buildRobotsPage(this.options);
1164
1216
  this._console.fileBuilt("robots.txt");
1217
+ if (doculaData.hasDocuments) {
1218
+ await this.buildFeedPage(doculaData);
1219
+ this._console.fileBuilt("feed.xml");
1220
+ }
1165
1221
  if (doculaData.hasDocuments) {
1166
1222
  this._console.step("Building documentation pages...");
1167
1223
  await this.buildDocsPages(doculaData);
@@ -1325,6 +1381,9 @@ var DoculaBuilder = class {
1325
1381
  async buildSiteMapPage(data) {
1326
1382
  const sitemapPath = `${data.output}/sitemap.xml`;
1327
1383
  const urls = [{ url: data.siteUrl }];
1384
+ if (data.documents?.length) {
1385
+ urls.push({ url: `${data.siteUrl}/feed.xml` });
1386
+ }
1328
1387
  if (data.openApiUrl && data.templates?.api) {
1329
1388
  urls.push({ url: `${data.siteUrl}/api` });
1330
1389
  }
@@ -1354,6 +1413,40 @@ var DoculaBuilder = class {
1354
1413
  await fs3.promises.mkdir(data.output, { recursive: true });
1355
1414
  await fs3.promises.writeFile(sitemapPath, xml, "utf8");
1356
1415
  }
1416
+ async buildFeedPage(data) {
1417
+ if (!data.documents?.length) {
1418
+ return;
1419
+ }
1420
+ const feedPath = `${data.output}/feed.xml`;
1421
+ const channelLink = this.buildAbsoluteSiteUrl(data.siteUrl, "/");
1422
+ const feedUrl = this.buildAbsoluteSiteUrl(data.siteUrl, "/feed.xml");
1423
+ let xml = '<?xml version="1.0" encoding="UTF-8"?>';
1424
+ xml += '<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">';
1425
+ xml += "<channel>";
1426
+ xml += `<title>${this.escapeXml(data.siteTitle)}</title>`;
1427
+ xml += `<link>${this.escapeXml(channelLink)}</link>`;
1428
+ xml += `<description>${this.escapeXml(data.siteDescription)}</description>`;
1429
+ xml += `<lastBuildDate>${this.escapeXml((/* @__PURE__ */ new Date()).toUTCString())}</lastBuildDate>`;
1430
+ xml += `<atom:link href="${this.escapeXml(feedUrl)}" rel="self" type="application/rss+xml" />`;
1431
+ for (const document of data.documents) {
1432
+ const itemTitle = document.navTitle || document.title || document.urlPath;
1433
+ const itemLink = this.buildAbsoluteSiteUrl(
1434
+ data.siteUrl,
1435
+ this.normalizePathForUrl(document.urlPath)
1436
+ );
1437
+ const summary = document.description || this.summarizeMarkdown(new Writr(document.content).body);
1438
+ xml += "<item>";
1439
+ xml += `<title>${this.escapeXml(itemTitle)}</title>`;
1440
+ xml += `<link>${this.escapeXml(itemLink)}</link>`;
1441
+ xml += `<guid isPermaLink="true">${this.escapeXml(itemLink)}</guid>`;
1442
+ xml += `<description>${this.escapeXml(summary)}</description>`;
1443
+ xml += "</item>";
1444
+ }
1445
+ xml += "</channel>";
1446
+ xml += "</rss>";
1447
+ await fs3.promises.mkdir(data.output, { recursive: true });
1448
+ await fs3.promises.writeFile(feedPath, xml, "utf8");
1449
+ }
1357
1450
  async buildLlmsFiles(data) {
1358
1451
  if (!this.options.enableLlmsTxt) {
1359
1452
  return;
@@ -1531,6 +1624,16 @@ var DoculaBuilder = class {
1531
1624
  }
1532
1625
  return urlPath;
1533
1626
  }
1627
+ escapeXml(value) {
1628
+ return String(value ?? "").replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&apos;");
1629
+ }
1630
+ summarizeMarkdown(markdown, maxLength = 240) {
1631
+ const plainText = markdown.replace(/^#{1,6}\s+.*$/gm, " ").replace(/^\s*[-*+]\s+/gm, " ").replace(/^\s*---+\s*$/gm, " ").replace(/```[\s\S]*?```/g, " ").replace(/`([^`]+)`/g, "$1").replace(/!\[([^\]]*)\]\([^)]+\)/g, "$1").replace(/\[([^\]]+)\]\([^)]+\)/g, "$1").replace(/[*_~>#]+/g, " ").replace(/\s+/g, " ").trim();
1632
+ if (plainText.length <= maxLength) {
1633
+ return plainText;
1634
+ }
1635
+ return `${plainText.slice(0, maxLength).trimEnd()}...`;
1636
+ }
1534
1637
  isRemoteUrl(url) {
1535
1638
  return /^https?:\/\//i.test(url);
1536
1639
  }
@@ -2649,6 +2752,7 @@ export {
2649
2752
  };
2650
2753
  /* v8 ignore next -- @preserve */
2651
2754
  /* v8 ignore next 3 -- @preserve */
2755
+ /* v8 ignore next 5 -- @preserve */
2652
2756
  /* v8 ignore next 2 -- @preserve */
2653
2757
  /* v8 ignore next 9 -- @preserve */
2654
2758
  /* v8 ignore next 4 -- @preserve */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "docula",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "description": "Beautiful Website for Your Projects",
5
5
  "type": "module",
6
6
  "main": "./dist/docula.js",
@@ -127,6 +127,32 @@
127
127
  align-items: center;
128
128
  }
129
129
 
130
+ .sidebar-links {
131
+ display: none;
132
+ }
133
+
134
+ @media screen and (min-width: 992px) {
135
+ .sidebar-links {
136
+ display: flex;
137
+ flex-direction: column;
138
+ gap: 0.5rem;
139
+ padding: 1rem 0;
140
+ border-top: 1px solid var(--border);
141
+ margin-top: 1rem;
142
+ }
143
+
144
+ .sidebar-header-link {
145
+ color: var(--sidebar-text);
146
+ text-decoration: none;
147
+ font-size: 0.875rem;
148
+ padding: 0.25rem 0;
149
+ }
150
+
151
+ .sidebar-header-link:hover {
152
+ text-decoration: underline;
153
+ }
154
+ }
155
+
130
156
  .header-logo {
131
157
  flex-shrink: 0;
132
158
  margin-right: 1.5rem;
@@ -5,6 +5,11 @@
5
5
  <a class="header-logo" href="/">
6
6
  <img src="/logo.svg" alt="logo" />
7
7
  </a>
8
+ {{#if headerLinks}}
9
+ {{#each headerLinks}}
10
+ <a class="header-link" href="{{this.url}}" target="_blank" rel="noopener noreferrer">{{#if this.icon}}{{{this.icon}}} {{/if}}{{this.label}}</a>
11
+ {{/each}}
12
+ {{/if}}
8
13
  <button id="open-sidebar" class="icon menu-btn">
9
14
  <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M0 96C0 78.3 14.3 64 32 64H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32C14.3 128 0 113.7 0 96zM0 256c0-17.7 14.3-32 32-32H416c17.7 0 32 14.3 32 32s-14.3 32-32 32H32c-17.7 0-32-14.3-32-32zM448 416c0 17.7-14.3 32-32 32H32c-17.7 0-32-14.3-32-32s14.3-32 32-32H416c17.7 0 32 14.3 32 32z"/></svg>
10
15
  <span>Menu</span>
@@ -36,6 +36,13 @@
36
36
  {{/forEach}}
37
37
  </ul>
38
38
 
39
+ {{#if headerLinks}}
40
+ <div class="sidebar-links">
41
+ {{#each headerLinks}}
42
+ <a class="sidebar-header-link" href="{{this.url}}" target="_blank" rel="noopener noreferrer">{{#if this.icon}}{{{this.icon}}} {{/if}}{{this.label}}</a>
43
+ {{/each}}
44
+ </div>
45
+ {{/if}}
39
46
  </div>
40
47
  </section>
41
48
 
@@ -58,17 +58,21 @@
58
58
  {{/each}}
59
59
  </div>
60
60
  {{/if}}
61
+ {{#if apiSpec.securitySchemes.length}}
61
62
  <div class="api-auth">
62
63
  <div class="api-auth__label">Authorization</div>
63
64
  <div class="api-auth__controls">
64
65
  <select class="api-auth__type" id="api-auth-type">
65
66
  <option value="none">None</option>
66
- <option value="apikey">API Key (x-api-key)</option>
67
- <option value="bearer">Bearer Token</option>
67
+ {{#each apiSpec.securitySchemes}}
68
+ <option value="{{this.key}}" data-scheme-type="{{this.type}}" data-scheme-scheme="{{this.scheme}}" data-scheme-name="{{this.name}}" data-scheme-in="{{this.in}}">{{this.description}}</option>
69
+ {{/each}}
68
70
  </select>
69
71
  <input type="password" class="api-auth__value api-auth__value--hidden" id="api-auth-value" placeholder="Enter value..." />
72
+ <span class="api-auth__cookie-status api-auth__cookie-status--hidden" id="api-auth-cookie-status"></span>
70
73
  </div>
71
74
  </div>
75
+ {{/if}}
72
76
  </section>
73
77
 
74
78
  {{#each apiSpec.groups}}
@@ -351,6 +351,36 @@
351
351
  display: none;
352
352
  }
353
353
 
354
+ .api-auth__cookie-status {
355
+ font-size: 0.8rem;
356
+ padding: 0.25rem 0.6rem;
357
+ border-radius: 6px;
358
+ }
359
+
360
+ .api-auth__cookie-status--hidden {
361
+ display: none;
362
+ }
363
+
364
+ .api-auth__cookie-status--ok {
365
+ color: #047857;
366
+ background: rgba(16, 185, 129, 0.15);
367
+ }
368
+
369
+ [data-theme="light"] .api-auth__cookie-status--ok {
370
+ color: #065f46;
371
+ background: rgba(16, 185, 129, 0.12);
372
+ }
373
+
374
+ .api-auth__cookie-status--warn {
375
+ color: #fbbf24;
376
+ background: rgba(251, 191, 36, 0.15);
377
+ }
378
+
379
+ [data-theme="light"] .api-auth__cookie-status--warn {
380
+ color: #92400e;
381
+ background: rgba(251, 191, 36, 0.12);
382
+ }
383
+
354
384
  /* Tag Group */
355
385
  .api-group {
356
386
  margin-bottom: 40px;
@@ -158,6 +158,17 @@ body {
158
158
  }
159
159
  }
160
160
 
161
+ .cookie-auth-user:empty {
162
+ display: none;
163
+ }
164
+
165
+ .cookie-auth-user {
166
+ font-weight: 500;
167
+ max-width: 150px;
168
+ overflow: hidden;
169
+ text-overflow: ellipsis;
170
+ }
171
+
161
172
  .cookie-auth-btn--fixed {
162
173
  position: fixed;
163
174
  bottom: 16px;
@@ -743,14 +754,30 @@ footer {
743
754
  color: var(--muted);
744
755
  }
745
756
 
746
- footer img {
747
- margin-left: 0.5rem;
748
- height: 1.5rem;
749
- width: auto;
757
+
758
+ .footer__link {
759
+ margin: 0 0.35rem;
760
+ color: var(--muted);
761
+ text-decoration: none;
762
+ display: inline-flex;
763
+ align-items: center;
764
+ }
765
+
766
+ .footer__link:hover {
767
+ color: var(--link);
768
+ }
769
+
770
+ .footer__icon {
771
+ width: 1rem;
772
+ height: 1rem;
773
+ }
774
+
775
+ .footer__bat {
776
+ width: 1.25rem;
777
+ height: 1.25rem;
750
778
  }
751
779
 
752
780
  @media (min-width: 992px) {
753
781
  footer { height: 4.75rem; }
754
- footer img { height: 1.75rem; }
755
782
  }
756
783
 
@@ -44,7 +44,7 @@
44
44
  </main>
45
45
  {{/if}}
46
46
 
47
- {{#if cookieAuth}}
47
+ {{#if cookieAuth.loginUrl}}
48
48
  <a href="{{cookieAuth.loginUrl}}" class="cookie-auth-btn cookie-auth-btn--fixed" id="cookie-auth-login">
49
49
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
50
50
  <span>Log In</span>
@@ -1,6 +1,14 @@
1
1
  <footer>
2
- Powered by
3
- <a href="https://docula.org/" target="_blank" rel="noopener noreferrer">
4
- <img src="https://docula.org/logo_horizontal.png" alt="docula logo" width="140" height="30" />
2
+ {{#if enableLlmsTxt}}
3
+ <a href="/llms.txt" class="footer__link">llms.txt</a>
4
+ {{/if}}
5
+ {{#if hasFeed}}
6
+ <a href="/feed.xml" class="footer__link" aria-label="RSS Feed">
7
+ <svg class="footer__icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"><circle cx="6.18" cy="17.82" r="2.18"/><path d="M4 4.44v2.83c7.03 0 12.73 5.7 12.73 12.73h2.83c0-8.59-6.97-15.56-15.56-15.56zm0 5.66v2.83c3.9 0 7.07 3.17 7.07 7.07h2.83c0-5.47-4.43-9.9-9.9-9.9z"/></svg>
8
+ </a>
9
+ {{/if}}
10
+ {{#if enableLlmsTxt}}{{else}}{{#if hasFeed}}{{else}}Built using {{/if}}{{/if}}
11
+ <a href="https://docula.org/" target="_blank" rel="noopener noreferrer" class="footer__link" aria-label="Docula">
12
+ <svg class="footer__bat" xmlns="http://www.w3.org/2000/svg" viewBox="70 110 360 205" fill="currentColor"><path d="M420,173c-16.76-18-72.78-36.65-131.59-30.58A160.15,160.15,0,0,0,269,120.55c-3.78,4.52-11.15,9.45-19,9.45s-15.23-4.93-19-9.45a160.15,160.15,0,0,0-19.4,21.83C152.78,136.31,96.76,154.94,80,173c48.86,5.86,79.5,34.09,79.5,72.06,52.37,0,77,28,90.5,59.94C263.54,273,288.13,245,340.5,245,340.5,207,371.14,178.8,420,173Z"/><circle cx="239.19" cy="145.36" r="3.32" fill="#fff"/><circle cx="260.81" cy="145.36" r="3.32" fill="#fff"/></svg>
5
13
  </a>
6
14
  </footer>
@@ -26,12 +26,25 @@
26
26
  <span>Changelog</span>
27
27
  </a>
28
28
  {{/if}}
29
+ {{#if headerLinks}}
30
+ {{#each headerLinks}}
31
+ <a class="header-bottom__item" href="{{this.url}}" target="_blank" rel="noopener noreferrer">
32
+ {{#if this.icon}}
33
+ {{{this.icon}}}
34
+ {{else}}
35
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
36
+ {{/if}}
37
+ <span>{{this.label}}</span>
38
+ </a>
39
+ {{/each}}
40
+ {{/if}}
29
41
  </nav>
30
- {{#if cookieAuth}}
42
+ {{#if cookieAuth.loginUrl}}
31
43
  <a href="{{cookieAuth.loginUrl}}" class="cookie-auth-btn" id="cookie-auth-login">
32
44
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
33
45
  <span>Log In</span>
34
46
  </a>
47
+ <span class="cookie-auth-user" id="cookie-auth-user" style="display:none"></span>
35
48
  <button class="cookie-auth-btn" id="cookie-auth-logout" style="display:none">
36
49
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
37
50
  <span>Log Out</span>
@@ -61,11 +74,24 @@
61
74
  <span>Changelog</span>
62
75
  </a>
63
76
  {{/if}}
64
- {{#if cookieAuth}}
77
+ {{#if headerLinks}}
78
+ {{#each headerLinks}}
79
+ <a class="mobile-nav__item" href="{{this.url}}" target="_blank" rel="noopener noreferrer">
80
+ {{#if this.icon}}
81
+ {{{this.icon}}}
82
+ {{else}}
83
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>
84
+ {{/if}}
85
+ <span>{{this.label}}</span>
86
+ </a>
87
+ {{/each}}
88
+ {{/if}}
89
+ {{#if cookieAuth.loginUrl}}
65
90
  <a class="mobile-nav__item" href="{{cookieAuth.loginUrl}}" id="cookie-auth-login-mobile">
66
91
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><polyline points="10 17 15 12 10 7"/><line x1="15" x2="3" y1="12" y2="12"/></svg>
67
92
  <span>Log In</span>
68
93
  </a>
94
+ <span class="cookie-auth-user" id="cookie-auth-user-mobile" style="display:none"></span>
69
95
  <button class="mobile-nav__item cookie-auth-btn--mobile" id="cookie-auth-logout-mobile" style="display:none">
70
96
  <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" x2="9" y1="12" y2="12"/></svg>
71
97
  <span>Log Out</span>
@@ -6,7 +6,8 @@
6
6
  <link rel="icon" href="/favicon.ico">
7
7
  <script>
8
8
  (function(){
9
- var mode = localStorage.getItem('theme') || {{#if themeMode}}'{{themeMode}}'{{else}}'system'{{/if}};
9
+ window.__doculaThemeKey = 'docula:theme:' + ({{#if siteUrl}}'{{siteUrl}}'{{else}}location.origin{{/if}}).replace(/^https?:\/\//, '');
10
+ var mode = localStorage.getItem(window.__doculaThemeKey) || {{#if themeMode}}'{{themeMode}}'{{else}}'system'{{/if}};
10
11
  var resolved = mode === 'system'
11
12
  ? (window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark')
12
13
  : mode;
@@ -9,8 +9,9 @@
9
9
  </script>
10
10
  <script>
11
11
  (function() {
12
+ var themeKey = window.__doculaThemeKey;
12
13
  function getMode() {
13
- return localStorage.getItem('theme') || {{#if themeMode}}'{{themeMode}}'{{else}}'system'{{/if}};
14
+ return localStorage.getItem(themeKey) || {{#if themeMode}}'{{themeMode}}'{{else}}'system'{{/if}};
14
15
  }
15
16
 
16
17
  function resolveTheme(mode) {
@@ -75,7 +76,7 @@
75
76
  if (els.toggle) {
76
77
  els.toggle.addEventListener('click', function() {
77
78
  currentMode = nextMode(currentMode);
78
- localStorage.setItem('theme', currentMode);
79
+ localStorage.setItem(themeKey, currentMode);
79
80
  applyTheme(currentMode);
80
81
  });
81
82
  }
@@ -105,20 +106,38 @@
105
106
  return false;
106
107
  }
107
108
  }
108
- function hasCookie() {
109
- return document.cookie.split(';').some(function(c) {
109
+ function getCookieValue() {
110
+ var match = document.cookie.split(';').find(function(c) {
110
111
  return c.trim().startsWith(cookieName + '=');
111
112
  });
113
+ return match ? match.trim().substring(cookieName.length + 1) : null;
114
+ }
115
+ function getDisplayName(token) {
116
+ try {
117
+ var payload = token.split('.')[1];
118
+ if (!payload) return null;
119
+ var json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));
120
+ var claims = JSON.parse(json);
121
+ return claims.name || claims.preferred_username || claims.email || null;
122
+ } catch (e) {
123
+ return null;
124
+ }
112
125
  }
113
126
  function updateAuthUI() {
114
- var loggedIn = hasCookie();
127
+ var token = getCookieValue();
128
+ var loggedIn = !!token;
129
+ var displayName = loggedIn ? getDisplayName(token) : null;
115
130
  var els = [
116
- { login: document.getElementById('cookie-auth-login'), logout: document.getElementById('cookie-auth-logout') },
117
- { login: document.getElementById('cookie-auth-login-mobile'), logout: document.getElementById('cookie-auth-logout-mobile') }
131
+ { login: document.getElementById('cookie-auth-login'), logout: document.getElementById('cookie-auth-logout'), user: document.getElementById('cookie-auth-user') },
132
+ { login: document.getElementById('cookie-auth-login-mobile'), logout: document.getElementById('cookie-auth-logout-mobile'), user: document.getElementById('cookie-auth-user-mobile') }
118
133
  ];
119
134
  els.forEach(function(pair) {
120
135
  if (pair.login) pair.login.style.display = loggedIn ? 'none' : '';
121
136
  if (pair.logout) pair.logout.style.display = loggedIn ? '' : 'none';
137
+ if (pair.user) {
138
+ pair.user.textContent = displayName || '';
139
+ pair.user.style.display = (loggedIn && displayName) ? '' : 'none';
140
+ }
122
141
  });
123
142
  }
124
143
  document.addEventListener('DOMContentLoaded', function() {
@@ -104,19 +104,52 @@ document.addEventListener('DOMContentLoaded', function() {
104
104
  group.classList.add('api-sidebar__group--collapsed');
105
105
  });
106
106
 
107
- // Auth type selector: show/hide value input
107
+ // Auth type selector: show/hide value input based on OpenAPI securitySchemes
108
108
  var authTypeSelect = document.getElementById('api-auth-type');
109
109
  var authValueInput = document.getElementById('api-auth-value');
110
+ var cookieStatusEl = document.getElementById('api-auth-cookie-status');
110
111
  if (authTypeSelect && authValueInput) {
111
- authTypeSelect.addEventListener('change', function() {
112
- if (this.value === 'none') {
112
+ function getSelectedSchemeData() {
113
+ var option = authTypeSelect.options[authTypeSelect.selectedIndex];
114
+ return {
115
+ key: option.value,
116
+ type: option.getAttribute('data-scheme-type'),
117
+ scheme: option.getAttribute('data-scheme-scheme'),
118
+ name: option.getAttribute('data-scheme-name'),
119
+ inLocation: option.getAttribute('data-scheme-in')
120
+ };
121
+ }
122
+ function updateAuthUI() {
123
+ var data = getSelectedSchemeData();
124
+ if (data.key === 'none') {
125
+ authValueInput.classList.add('api-auth__value--hidden');
126
+ authValueInput.value = '';
127
+ if (cookieStatusEl) cookieStatusEl.classList.add('api-auth__cookie-status--hidden');
128
+ } else if (data.type === 'apiKey' && data.inLocation === 'cookie') {
113
129
  authValueInput.classList.add('api-auth__value--hidden');
114
130
  authValueInput.value = '';
131
+ if (cookieStatusEl) {
132
+ cookieStatusEl.classList.remove('api-auth__cookie-status--hidden');
133
+ var configEl = document.getElementById('cookie-auth-config');
134
+ var cookieName = configEl ? configEl.getAttribute('data-cookie-name') : (data.name || 'token');
135
+ var hasCookie = document.cookie.split(';').some(function(c) { return c.trim().startsWith(cookieName + '='); });
136
+ cookieStatusEl.textContent = hasCookie ? 'Logged in' : 'Not logged in — use Login button above';
137
+ cookieStatusEl.className = 'api-auth__cookie-status' + (hasCookie ? ' api-auth__cookie-status--ok' : ' api-auth__cookie-status--warn');
138
+ }
115
139
  } else {
116
140
  authValueInput.classList.remove('api-auth__value--hidden');
117
- authValueInput.placeholder = this.value === 'apikey' ? 'Enter API key...' : 'Enter token...';
141
+ if (data.type === 'apiKey') {
142
+ authValueInput.placeholder = 'Enter API key...';
143
+ } else if (data.scheme === 'bearer') {
144
+ authValueInput.placeholder = 'Enter bearer token...';
145
+ } else {
146
+ authValueInput.placeholder = 'Enter value...';
147
+ }
148
+ if (cookieStatusEl) cookieStatusEl.classList.add('api-auth__cookie-status--hidden');
118
149
  }
119
- });
150
+ }
151
+ authTypeSelect.addEventListener('change', updateAuthUI);
152
+ updateAuthUI();
120
153
  }
121
154
 
122
155
  // Helper: expand an operation and its corresponding sidebar group
@@ -226,15 +259,28 @@ document.addEventListener('DOMContentLoaded', function() {
226
259
  if (value) headers[name] = value;
227
260
  });
228
261
 
229
- // Inject global auth header
262
+ // Inject global auth header from selected security scheme
230
263
  var authType = document.getElementById('api-auth-type');
231
264
  var authValue = document.getElementById('api-auth-value');
232
- if (authType && authValue && authValue.value.trim()) {
233
- var authVal = authValue.value.trim();
234
- if (authType.value === 'apikey') {
235
- headers['x-api-key'] = authVal;
236
- } else if (authType.value === 'bearer') {
237
- headers['Authorization'] = 'Bearer ' + authVal;
265
+ var useCookieAuth = false;
266
+ if (authType && authType.value !== 'none') {
267
+ var authOption = authType.options[authType.selectedIndex];
268
+ var schemeType = authOption.getAttribute('data-scheme-type');
269
+ var schemeName = authOption.getAttribute('data-scheme-name');
270
+ var schemeIn = authOption.getAttribute('data-scheme-in');
271
+ var schemeScheme = authOption.getAttribute('data-scheme-scheme');
272
+ if (schemeType === 'apiKey' && schemeIn === 'cookie') {
273
+ useCookieAuth = true;
274
+ } else if (authValue && authValue.value.trim()) {
275
+ var authVal = authValue.value.trim();
276
+ if (schemeType === 'apiKey' && schemeIn === 'header' && schemeName) {
277
+ headers[schemeName] = authVal;
278
+ } else if (schemeType === 'http' && schemeScheme === 'bearer') {
279
+ headers['Authorization'] = 'Bearer ' + authVal;
280
+ } else if (schemeType === 'apiKey' && schemeIn === 'query' && schemeName) {
281
+ queryParts.push(encodeURIComponent(schemeName) + '=' + encodeURIComponent(authVal));
282
+ queryString = '?' + queryParts.join('&');
283
+ }
238
284
  }
239
285
  }
240
286
 
@@ -247,6 +293,9 @@ document.addEventListener('DOMContentLoaded', function() {
247
293
 
248
294
  var url = baseUrl + path + queryString;
249
295
  var fetchOptions = { method: method, headers: headers };
296
+ if (useCookieAuth) {
297
+ fetchOptions.credentials = 'include';
298
+ }
250
299
  if (body && method !== 'GET' && method !== 'HEAD') {
251
300
  fetchOptions.body = body;
252
301
  }