jamdesk 1.1.211 → 1.1.213

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.
Files changed (59) hide show
  1. package/dist/__tests__/unit/deploy.test.js +13 -0
  2. package/dist/__tests__/unit/deploy.test.js.map +1 -1
  3. package/dist/__tests__/unit/docs-config.test.js +26 -0
  4. package/dist/__tests__/unit/docs-config.test.js.map +1 -1
  5. package/dist/__tests__/unit/dusk-embargo.test.d.ts +2 -0
  6. package/dist/__tests__/unit/dusk-embargo.test.d.ts.map +1 -0
  7. package/dist/__tests__/unit/dusk-embargo.test.js +25 -0
  8. package/dist/__tests__/unit/dusk-embargo.test.js.map +1 -0
  9. package/dist/__tests__/unit/migrate-resolve-theme.test.js +11 -0
  10. package/dist/__tests__/unit/migrate-resolve-theme.test.js.map +1 -1
  11. package/dist/commands/deploy.js +4 -4
  12. package/dist/commands/deploy.js.map +1 -1
  13. package/dist/commands/migrate/index.d.ts.map +1 -1
  14. package/dist/commands/migrate/index.js +7 -3
  15. package/dist/commands/migrate/index.js.map +1 -1
  16. package/dist/commands/migrate/types.d.ts +4 -1
  17. package/dist/commands/migrate/types.d.ts.map +1 -1
  18. package/dist/commands/migrate/types.js +13 -1
  19. package/dist/commands/migrate/types.js.map +1 -1
  20. package/dist/index.js +8 -2
  21. package/dist/index.js.map +1 -1
  22. package/dist/lib/docs-config.d.ts +1 -1
  23. package/dist/lib/docs-config.d.ts.map +1 -1
  24. package/dist/lib/docs-config.js +1 -1
  25. package/dist/lib/docs-config.js.map +1 -1
  26. package/dist/lib/dusk-embargo.d.ts +15 -0
  27. package/dist/lib/dusk-embargo.d.ts.map +1 -0
  28. package/dist/lib/dusk-embargo.js +15 -0
  29. package/dist/lib/dusk-embargo.js.map +1 -0
  30. package/dist/lib/normalize-config.js +1 -1
  31. package/dist/lib/normalize-config.js.map +1 -1
  32. package/package.json +1 -1
  33. package/vendored/app/(unlock)/jd/unlock/page.tsx +2 -2
  34. package/vendored/components/layout/LayoutWrapper.tsx +7 -3
  35. package/vendored/components/navigation/DefaultLogo.tsx +25 -18
  36. package/vendored/components/navigation/Header.tsx +78 -29
  37. package/vendored/components/navigation/Sidebar.tsx +5 -3
  38. package/vendored/components/navigation/SocialFooter.tsx +5 -6
  39. package/vendored/components/navigation/ThemePreviewChips.tsx +3 -2
  40. package/vendored/lib/branding-url.ts +9 -0
  41. package/vendored/lib/build/error-parser.ts +1 -1
  42. package/vendored/lib/docs-types.ts +5 -1
  43. package/vendored/lib/dusk-embargo.ts +16 -0
  44. package/vendored/lib/extract-highlights.ts +1 -1
  45. package/vendored/lib/layout-helpers.tsx +19 -2
  46. package/vendored/lib/normalize-config.ts +1 -1
  47. package/vendored/lib/r2-cleanup.ts +14 -10
  48. package/vendored/lib/static-artifacts.ts +84 -30
  49. package/vendored/lib/static-file-route.ts +8 -7
  50. package/vendored/lib/theme-preview.ts +16 -8
  51. package/vendored/lib/validate-config.ts +4 -4
  52. package/vendored/schema/docs-schema.json +124 -0
  53. package/vendored/themes/dusk/variables.css +426 -0
  54. package/vendored/themes/index.ts +28 -0
  55. package/vendored/themes/jam/variables.css +6 -5
  56. package/vendored/themes/nebula/variables.css +1 -1
  57. package/vendored/themes/pulsar/variables.css +4 -3
  58. package/vendored/themes/types.ts +8 -0
  59. package/vendored/workspace-package-lock.json +9 -9
@@ -3,9 +3,7 @@
3
3
  import type { CSSProperties } from 'react';
4
4
  import type { DocsConfig, SocialPlatform, FooterLinkColumn } from '@/lib/docs-types';
5
5
  import { getIconClass } from '@/lib/icon-utils';
6
- import { getBrandingUrl } from '@/lib/branding-url';
7
-
8
- const showBranding = process.env.NEXT_PUBLIC_SHOW_BRANDING !== 'false';
6
+ import { getBrandingUrl, isBrandingVisible } from '@/lib/branding-url';
9
7
 
10
8
  // Wordmark renders as a CSS mask so its fill inherits `currentColor`, letting
11
9
  // the link's text color (and hover state) drive the SVG color in one place.
@@ -149,9 +147,8 @@ function SocialIcons({ socials }: { socials: Partial<Record<SocialPlatform, stri
149
147
  }
150
148
 
151
149
  /** "Powered by Jamdesk" attribution link, shared by the full footer and the
152
- * embed footer. Renders nothing when branding is disabled at build time. */
150
+ * embed footer. Callers decide whether to render it (isBrandingVisible). */
153
151
  function BrandingLink({ projectSlug }: { projectSlug?: string }) {
154
- if (!showBranding) return null;
155
152
  return (
156
153
  <a
157
154
  href={getBrandingUrl(projectSlug)}
@@ -171,6 +168,8 @@ function BrandingLink({ projectSlug }: { projectSlug?: string }) {
171
168
  }
172
169
 
173
170
  export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFooterProps) {
171
+ const showBranding = isBrandingVisible(config);
172
+
174
173
  // Embed render (widget modal): keep ONLY the "Powered by Jamdesk" attribution.
175
174
  // The link columns + social icons read as out of place inside an embedded
176
175
  // changelog; the attribution stays even when the normal footer is hidden,
@@ -202,7 +201,7 @@ export function SocialFooter({ config, hidden, projectSlug, embed }: SocialFoote
202
201
  {hasLinks && <LinkColumns columns={links} />}
203
202
  <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
204
203
  {hasSocials && <SocialIcons socials={socials} />}
205
- <BrandingLink projectSlug={projectSlug} />
204
+ {showBranding && <BrandingLink projectSlug={projectSlug} />}
206
205
  </div>
207
206
  </footer>
208
207
  );
@@ -4,7 +4,8 @@ import { useId, useState } from 'react';
4
4
  import { useLinkPrefix } from '@/lib/link-prefix-context';
5
5
  import { useThemePreview } from '@/lib/theme-preview-context';
6
6
  import { applyThemePreview, type ThemePreviewSurface } from '@/lib/theme-preview-client';
7
- import { getAllThemes, type ThemeName } from '@/themes';
7
+ import { getPreviewThemes } from '@/lib/theme-preview';
8
+ import type { ThemeName } from '@/themes';
8
9
 
9
10
  /**
10
11
  * Jamdesk-only affordance: re-skins jamdesk.com/docs with any built-in theme
@@ -52,7 +53,7 @@ export function ThemePreviewChips() {
52
53
  applyThemePreview(name, { linkPrefix, surface });
53
54
  }
54
55
 
55
- const themes = getAllThemes();
56
+ const themes = getPreviewThemes();
56
57
  const previewing = activeTheme !== defaultTheme;
57
58
  const activeName = themes.find((t) => t.name === activeTheme)?.displayName ?? activeTheme;
58
59
 
@@ -7,3 +7,12 @@ export function getBrandingUrl(projectSlug?: string | null): string {
7
7
  );
8
8
  return `https://www.jamdesk.com?utm_campaign=poweredBy&utm_medium=referral&utm_source=${slug}`;
9
9
  }
10
+
11
+ /** Whether to show "Powered by Jamdesk". The build writes `_showBranding: false`
12
+ * into the site config only when the owner hid it, so a missing setting (an
13
+ * older build, local preview) or a missing config keeps the badge. */
14
+ export function isBrandingVisible(
15
+ config: { _showBranding?: boolean } | null | undefined,
16
+ ): boolean {
17
+ return config?._showBranding !== false;
18
+ }
@@ -262,7 +262,7 @@ export function parseErrorDetails(
262
262
  const requiredFields =
263
263
  'Required fields:\n' +
264
264
  '• "name": Your site name\n' +
265
- '• "theme": One of "jam", "nebula", "pulsar", or "halo"\n' +
265
+ '• "theme": One of "jam", "nebula", "pulsar", "halo", or "dusk"\n' +
266
266
  '• "colors": { "primary": "#hexcolor" }\n' +
267
267
  '• "navigation": one of "tabs", "groups", "pages", "dropdowns", ' +
268
268
  '"versions", "languages", or "products" ' +
@@ -23,7 +23,7 @@ export const ASSET_PREFIX = '/_jd';
23
23
  /**
24
24
  * Available themes
25
25
  */
26
- export type ThemeName = 'jam' | 'nebula' | 'pulsar' | 'halo';
26
+ export type ThemeName = 'jam' | 'nebula' | 'pulsar' | 'halo' | 'dusk';
27
27
 
28
28
  // =============================================================================
29
29
  // ICON TYPES
@@ -973,6 +973,10 @@ export interface DocsConfig {
973
973
  _hasCustomCss?: boolean;
974
974
  _hasCustomJs?: boolean;
975
975
 
976
+ // Set by the build to `false` only when the owner hid "Powered by Jamdesk"
977
+ // (paid plans). Absent means shown. Read by isBrandingVisible.
978
+ _showBranding?: boolean;
979
+
976
980
  // Runtime: Set by ISR middleware based on project config
977
981
  // When true, all navigation links should be prefixed with /docs
978
982
  hostAtDocs?: boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Dusk launch embargo — flip to `true` on October 1, 2026.
3
+ *
4
+ * One of four twins (build-service, CLI, dashboard, marketing); separate apps
5
+ * with separate deploys, so they cannot share a module. Flip all four in one
6
+ * commit. See docs/plans/2026-09-23-dusk-october-1-release-plan.md.
7
+ *
8
+ * Here it gates the jamdesk-docs theme picker, which lists every registry
9
+ * theme and is live on jamdesk.com/docs — without it the next ISR deploy
10
+ * would announce Dusk. The registry, schema and validation are NOT gated: a
11
+ * docs.json that names "dusk" must build (the demo site soaks on it).
12
+ *
13
+ * The `: boolean` annotation is LOAD-BEARING: without it TS narrows to the
14
+ * literal `false` and stops type-checking the launch branch.
15
+ */
16
+ export const DUSK_PUBLIC: boolean = false;
@@ -11,7 +11,7 @@ import type { DocsConfig } from './docs-types.js';
11
11
  */
12
12
  export interface ExtractedHighlights {
13
13
  siteName: string;
14
- theme: 'jam' | 'nebula' | 'pulsar' | 'halo';
14
+ theme: 'jam' | 'nebula' | 'pulsar' | 'halo' | 'dusk';
15
15
  primaryColor?: string;
16
16
  seoIndexable: boolean;
17
17
  analyticsIntegrations: string[];
@@ -2,7 +2,7 @@
2
2
  // language, project slug) are resolved upstream from middleware headers
3
3
  // or — in non-ISR dev/tests — from URL params; this module owns the
4
4
  // rendering once those have resolved.
5
- import { Inter, JetBrains_Mono, Figtree } from 'next/font/google';
5
+ import { Inter, JetBrains_Mono, Figtree, IBM_Plex_Serif } from 'next/font/google';
6
6
  import { preinit } from 'react-dom';
7
7
  import fs from 'fs';
8
8
  import path from 'path';
@@ -107,6 +107,18 @@ export const figtree = Figtree({
107
107
  preload: false,
108
108
  });
109
109
 
110
+ // Dusk's H1 face. Exposed as a variable only (--font-serif) — Dusk keeps Inter
111
+ // on the body. Not a variable font, so the weight ladder is explicit.
112
+ // preload: false for the same reason as figtree: the theme is only known at
113
+ // runtime from docs.json, so preload would be all-pages-or-none.
114
+ export const ibmPlexSerif = IBM_Plex_Serif({
115
+ subsets: ['latin'],
116
+ display: 'swap',
117
+ weight: ['400', '600'],
118
+ variable: '--font-serif',
119
+ preload: false,
120
+ });
121
+
110
122
  export function getLocalFileContent(filename: string): string | null {
111
123
  try {
112
124
  const filePath = path.join(process.cwd(), 'public', filename);
@@ -315,6 +327,7 @@ export function getFontClassName(
315
327
  themeName: ThemeName | undefined,
316
328
  customFonts?: FontConfig,
317
329
  ): string {
330
+ const theme = getTheme(themeName);
318
331
  const primaryFont = getPrimaryFontFamily(customFonts);
319
332
 
320
333
  if (primaryFont) {
@@ -333,16 +346,20 @@ export function getFontClassName(
333
346
  }
334
347
 
335
348
  classes.push(jetbrainsMono.variable);
349
+ // Dusk's serif H1 survives a custom body font — it is theme identity.
350
+ if (theme.name === 'dusk') classes.push(ibmPlexSerif.variable);
336
351
  return classes.join(' ');
337
352
  }
338
353
 
339
- const theme = getTheme(themeName);
340
354
  if (theme.name === 'nebula') {
341
355
  return `${jetbrainsMono.variable} font-mono`;
342
356
  }
343
357
  if (theme.name === 'halo') {
344
358
  return `${figtree.variable} ${jetbrainsMono.variable} ${figtree.className}`;
345
359
  }
360
+ if (theme.name === 'dusk') {
361
+ return `${inter.variable} ${ibmPlexSerif.variable} ${jetbrainsMono.variable} ${inter.className}`;
362
+ }
346
363
  return `${inter.variable} ${jetbrainsMono.variable} ${inter.className}`;
347
364
  }
348
365
 
@@ -96,7 +96,7 @@ export function normalizeConfig(config: DocsConfigInput): NormalizeResult {
96
96
  // 3. Warn about layout
97
97
  if (layout) {
98
98
  warnings.push(
99
- 'layout field is ignored. Jamdesk determines layout from theme (pulsar=sidebar, jam/nebula/halo=header).'
99
+ 'layout field is ignored. Jamdesk determines layout from theme (pulsar=sidebar, jam/nebula/halo/dusk=header).'
100
100
  );
101
101
  }
102
102
 
@@ -409,9 +409,13 @@ export async function pruneRemovedContent(
409
409
 
410
410
  /**
411
411
  * Given top-level CommonPrefixes from a delimiter='/' listing of `{slug}/`,
412
- * return per-locale llms.txt keys whose locale is a KNOWN language code but
413
- * is no longer declared — left behind when a project removes a language.
414
- * Only ever targets our own `<locale>/llms.txt` artifact keys, so content
412
+ * return per-locale llms.txt / llms-full.txt keys whose locale is a KNOWN
413
+ * language code but is no longer active — left behind when a project removes
414
+ * a language. One set covers both files: the default locale has only an
415
+ * llms-full.txt, and treating its dir as active keeps the sweep from deleting
416
+ * a `<default>/llms.txt` that never existed on every build. The cost is a
417
+ * cosmetic orphan if the default language ever changes.
418
+ * Only ever targets our own `<locale>/llms*.txt` artifact keys, so content
415
419
  * dirs (content/, snippets/, openapi/, assets/) can never be touched.
416
420
  */
417
421
  export function staleLocaleLlmsKeys(
@@ -430,14 +434,14 @@ export function staleLocaleLlmsKeys(
430
434
  // casing differs is either a renamed locale or a leftover from a prior
431
435
  // casing change — both legitimately stale.
432
436
  if (activeLocales.has(dir)) continue;
433
- keys.push(`${projectSlug}/${dir}/llms.txt`);
437
+ keys.push(`${projectSlug}/${dir}/llms.txt`, `${projectSlug}/${dir}/llms-full.txt`);
434
438
  }
435
439
  return keys;
436
440
  }
437
441
 
438
442
  /**
439
- * List `{slug}/`'s top-level dirs and delete `<locale>/llms.txt` for any
440
- * known-language dir no longer declared. Paginates the listing like
443
+ * List `{slug}/`'s top-level dirs and delete `<locale>/llms.txt` and
444
+ * `<locale>/llms-full.txt` for any known-language dir no longer active. Paginates the listing like
441
445
  * deleteAllProjectR2Objects — a delimiter listing of a huge project could
442
446
  * truncate, and an abandoned page would silently orphan its stale locales.
443
447
  * Non-fatal by contract: callers treat a throw as a warning — a stale
@@ -474,15 +478,15 @@ export async function sweepStaleLocaleLlmsTxt(
474
478
  } while (continuationToken);
475
479
 
476
480
  const staleKeys = staleLocaleLlmsKeys(prefixes, projectSlug, activeLocales);
477
- for (const key of staleKeys) {
478
- await client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }));
479
- }
481
+ await Promise.all(
482
+ staleKeys.map((key) => client.send(new DeleteObjectCommand({ Bucket: bucketName, Key: key }))),
483
+ );
480
484
  return staleKeys;
481
485
  }
482
486
 
483
487
  /**
484
488
  * Delete a project's AI-context files: the root `llms.txt` + `llms-full.txt` and
485
- * every per-locale `<locale>/llms.txt`. Called on every build where `seo.ai.llmsTxt`
489
+ * every per-locale `<locale>/llms.txt` + `<locale>/llms-full.txt`. Called on every build where `seo.ai.llmsTxt`
486
490
  * is false so a previously published llms.txt stops serving after a customer opts
487
491
  * out — the build gate stops REGENERATING these files but otherwise leaves the
488
492
  * already-uploaded copies in place. The deletes are idempotent (DeleteObject is a
@@ -303,19 +303,26 @@ export interface LlmsTxtFilesOptions extends Omit<LlmsTxtOptions, 'sections' | '
303
303
  }
304
304
 
305
305
  /**
306
- * Render the root llms.txt (default locale) plus one file per declared,
307
- * non-hidden, non-default language. Locale membership comes from the page
308
- * path prefix (resolveLocaleFromPath, whitelisted against declared codes —
309
- * same semantics as the search index), NOT from nav position. Cross-links
310
- * between locale files render under the trailing "## Optional" section.
306
+ * Pages grouped by locale for the per-locale AI-context files (llms.txt,
307
+ * llms-full.txt). Shared so the two can never disagree on which page belongs
308
+ * to which language. Null when the project declares no visible languages.
311
309
  */
312
- export function generateLlmsTxtFiles(
313
- options: LlmsTxtFilesOptions,
314
- ): { root: string; byLocale: Record<string, string> } {
315
- const { navigation, languages, baseUrl, hostAtDocs = false, docsPrefix, pages, ...rest } = options;
316
- const urlPrefix = docsPrefix ?? (hostAtDocs ? '/docs' : '');
317
- const sections = extractNavigationSections(navigation);
310
+ interface LocaleBuckets<P> {
311
+ defaultCode: string;
312
+ /** Declared, non-hidden codes that have pages; the default always included. */
313
+ activeCodes: string[];
314
+ pagesFor: (code: string) => P[];
315
+ }
318
316
 
317
+ /**
318
+ * Bucket pages by locale. Membership comes from the page path prefix
319
+ * (resolveLocaleFromPath, whitelisted against declared codes — same semantics
320
+ * as the search index), NOT from nav position.
321
+ */
322
+ function bucketPagesByLocale<P extends { path: string }>(
323
+ pages: P[],
324
+ languages: LanguageConfig[] | undefined,
325
+ ): LocaleBuckets<P> | null {
319
326
  const declared = (languages ?? []).filter(
320
327
  (l): l is LanguageConfig & { language: string } =>
321
328
  typeof l.language === 'string' && !l.hidden,
@@ -329,14 +336,11 @@ export function generateLlmsTxtFiles(
329
336
  (code, i, all) =>
330
337
  all.findIndex((c) => c.toLowerCase() === code.toLowerCase()) === i,
331
338
  );
339
+ if (codes.length === 0) return null;
332
340
  const defaultCode = resolveLanguageWithFallback(null, languages);
341
+ const loweredCodes = buildLoweredLocaleSet(codes);
333
342
 
334
- if (codes.length === 0) {
335
- const root = generateLlmsTxt({ ...rest, baseUrl, hostAtDocs, docsPrefix, pages, sections });
336
- return { root, byLocale: {} };
337
- }
338
-
339
- const byLocalePages = new Map<string, typeof pages>();
343
+ const byLocalePages = new Map<string, P[]>();
340
344
  for (const page of pages) {
341
345
  // resolveLocaleFromPath returns '' (NOT the default code) for unprefixed
342
346
  // paths and for prefixes outside the whitelist — both belong to the root
@@ -346,21 +350,43 @@ export function generateLlmsTxtFiles(
346
350
  // CANONICAL casing (e.g. 'fr-CA' for a declared 'fr-ca'), so bucketing by
347
351
  // its raw return and reading back by the declared code would silently
348
352
  // drop those pages from every llms.txt file.
349
- const locale = (resolveLocaleFromPath(page.path, codes) || defaultCode).toLowerCase();
353
+ const locale = (resolveLocaleWithLoweredSet(page.path, loweredCodes) || defaultCode).toLowerCase();
350
354
  if (!byLocalePages.has(locale)) byLocalePages.set(locale, []);
351
355
  byLocalePages.get(locale)!.push(page);
352
356
  }
357
+ const pagesFor = (code: string): P[] => byLocalePages.get(code.toLowerCase()) ?? [];
358
+
359
+ // Declared-but-untranslated languages produce empty files nobody should be
360
+ // sent to — generate and cross-link only locales that actually have pages
361
+ // (the default locale always exists as the root file).
362
+ const activeCodes = codes.filter((c) => c === defaultCode || pagesFor(c).length > 0);
363
+ return { defaultCode, activeCodes, pagesFor };
364
+ }
365
+
366
+ /**
367
+ * Render the root llms.txt (default locale) plus one file per declared,
368
+ * non-hidden, non-default language (see bucketPagesByLocale for membership).
369
+ * Cross-links between locale files render under the trailing "## Optional"
370
+ * section.
371
+ */
372
+ export function generateLlmsTxtFiles(
373
+ options: LlmsTxtFilesOptions,
374
+ ): { root: string; byLocale: Record<string, string> } {
375
+ const { navigation, languages, baseUrl, hostAtDocs = false, docsPrefix, pages, ...rest } = options;
376
+ const urlPrefix = docsPrefix ?? (hostAtDocs ? '/docs' : '');
377
+ const sections = extractNavigationSections(navigation);
378
+
379
+ const buckets = bucketPagesByLocale(pages, languages);
380
+ if (!buckets) {
381
+ const root = generateLlmsTxt({ ...rest, baseUrl, hostAtDocs, docsPrefix, pages, sections });
382
+ return { root, byLocale: {} };
383
+ }
384
+ const { defaultCode, activeCodes, pagesFor } = buckets;
353
385
 
354
386
  const llmsUrl = (code: string): string =>
355
387
  code === defaultCode
356
388
  ? `${baseUrl}${urlPrefix}/llms.txt`
357
389
  : `${baseUrl}${urlPrefix}/${code}/llms.txt`;
358
- // Declared-but-untranslated languages produce empty files nobody should be
359
- // sent to — generate and cross-link only locales that actually have pages
360
- // (the default locale always exists as the root file).
361
- const activeCodes = codes.filter(
362
- (c) => c === defaultCode || (byLocalePages.get(c.toLowerCase())?.length ?? 0) > 0,
363
- );
364
390
  const activeCrossLinks = (selfCode: string) =>
365
391
  activeCodes
366
392
  .filter((c) => c !== selfCode)
@@ -368,7 +394,7 @@ export function generateLlmsTxtFiles(
368
394
 
369
395
  const root = generateLlmsTxt({
370
396
  ...rest, baseUrl, hostAtDocs, docsPrefix, sections,
371
- pages: byLocalePages.get(defaultCode.toLowerCase()) ?? [],
397
+ pages: pagesFor(defaultCode),
372
398
  otherLanguages: activeCrossLinks(defaultCode),
373
399
  });
374
400
 
@@ -377,7 +403,7 @@ export function generateLlmsTxtFiles(
377
403
  if (code === defaultCode) continue;
378
404
  byLocale[code] = generateLlmsTxt({
379
405
  ...rest, baseUrl, hostAtDocs, docsPrefix, sections,
380
- pages: byLocalePages.get(code.toLowerCase()) ?? [],
406
+ pages: pagesFor(code),
381
407
  otherLanguages: activeCrossLinks(code),
382
408
  });
383
409
  }
@@ -723,6 +749,30 @@ export function generateLlmsFullTxt(options: LlmsFullTxtOptions): string {
723
749
  return parts.join('').trim();
724
750
  }
725
751
 
752
+ /**
753
+ * Render the root llms-full.txt plus one file per active locale.
754
+ *
755
+ * Unlike llms.txt, the root stays EVERY language: existing consumers of the
756
+ * combined file keep receiving what they always have. Because of that, the
757
+ * default locale gets its own file too (e.g. /en/llms-full.txt) — otherwise
758
+ * there would be no way to fetch the default language alone.
759
+ */
760
+ export function generateLlmsFullTxtFiles(
761
+ options: LlmsFullTxtOptions & { languages?: LanguageConfig[] },
762
+ ): { root: string; byLocale: Record<string, string> } {
763
+ const { languages, ...rest } = options;
764
+ const root = generateLlmsFullTxt(rest);
765
+ const buckets = bucketPagesByLocale(rest.pages, languages);
766
+ // One active locale means the root already IS that language — a per-locale
767
+ // copy would only duplicate a multi-MB file.
768
+ if (!buckets || buckets.activeCodes.length < 2) return { root, byLocale: {} };
769
+ const byLocale: Record<string, string> = {};
770
+ for (const code of buckets.activeCodes) {
771
+ byLocale[code] = generateLlmsFullTxt({ ...rest, pages: buckets.pagesFor(code) });
772
+ }
773
+ return { root, byLocale };
774
+ }
775
+
726
776
  /**
727
777
  * Options for generating all artifacts.
728
778
  */
@@ -775,6 +825,8 @@ export interface GeneratedArtifacts {
775
825
  /** Non-default-locale llms.txt files, keyed by language code. */
776
826
  llmsTxtByLocale: Record<string, string>;
777
827
  llmsFullTxt: string;
828
+ /** Per-locale llms-full.txt files, keyed by language code (default locale included). */
829
+ llmsFullTxtByLocale: Record<string, string>;
778
830
  robotsTxt: string;
779
831
  rssFeed: string | null;
780
832
  /** changelog.json for the embeddable widget — built from the same updates as rssFeed. */
@@ -856,9 +908,9 @@ export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArti
856
908
  name, description, baseUrl, pages, hostAtDocs, docsPrefix, noindex, visibility,
857
909
  navigation, languages,
858
910
  });
859
- const llmsFullTxt = llmsFullPages
860
- ? generateLlmsFullTxt({ name, pages: llmsFullPages, noindex, visibility })
861
- : '';
911
+ const { root: llmsFullTxt, byLocale: llmsFullTxtByLocale } = llmsFullPages
912
+ ? generateLlmsFullTxtFiles({ name, pages: llmsFullPages, noindex, visibility, languages })
913
+ : { root: '', byLocale: {} };
862
914
  const robotsTxt = generateRobotsTxt({ baseUrl, hostAtDocs, docsPrefix, noindex });
863
915
 
864
916
  // Extract <Update> entries once — they power BOTH the RSS feed and
@@ -877,7 +929,9 @@ export function generateAllArtifacts(options: GenerateAllOptions): GeneratedArti
877
929
 
878
930
  const changelog = generateChangelog(updates);
879
931
 
880
- return { sitemap, llmsTxt, llmsTxtByLocale, llmsFullTxt, robotsTxt, rssFeed, changelog };
932
+ return {
933
+ sitemap, llmsTxt, llmsTxtByLocale, llmsFullTxt, llmsFullTxtByLocale, robotsTxt, rssFeed, changelog,
934
+ };
881
935
  }
882
936
 
883
937
  // =============================================================================
@@ -25,7 +25,7 @@ export const STATIC_FILE_NAMES = [
25
25
  ] as const;
26
26
 
27
27
  /** All CDN paths for static file routes — used by revalidation to purge CDN cache. */
28
- // Per-locale llms.txt paths (/{locale}/llms.txt) are intentionally absent:
28
+ // Per-locale llms.txt / llms-full.txt paths (/{locale}/llms.txt) are intentionally absent:
29
29
  // locales are per-project, unenumerable here. They self-heal via s-maxage=3600,
30
30
  // identical to llms.txt's behavior between explicit purges.
31
31
  export const STATIC_REVALIDATION_PATHS = STATIC_FILE_NAMES.flatMap(
@@ -165,7 +165,8 @@ export function createCorsStaticFileHandler(
165
165
  }
166
166
 
167
167
  /**
168
- * GET handler for per-locale llms.txt (`/{locale}/llms.txt`, `/docs/{locale}/llms.txt`).
168
+ * GET handler for per-locale llms.txt / llms-full.txt (`/{locale}/llms.txt`,
169
+ * `/docs/{locale}/llms-full.txt`, ...).
169
170
  *
170
171
  * Locale is validated (case-insensitively) against the supported language-code
171
172
  * list BEFORE any R2 I/O so arbitrary path segments (`/foo/llms.txt`) fast-404.
@@ -173,7 +174,7 @@ export function createCorsStaticFileHandler(
173
174
  * `x-jd-noindex` — AI agents fetch them directly and don't honor robots
174
175
  * semantics.
175
176
  */
176
- export function createLocaleLlmsTxtHandler(): (
177
+ export function createLocaleLlmsTxtHandler(file: 'llms.txt' | 'llms-full.txt' = 'llms.txt'): (
177
178
  request: NextRequest,
178
179
  ctx: { params: Promise<{ locale: string }> },
179
180
  ) => Promise<NextResponse> {
@@ -185,7 +186,7 @@ export function createLocaleLlmsTxtHandler(): (
185
186
  // Fetch with the segment verbatim: R2 keys are written with the DECLARED
186
187
  // code, and every advertised URL is generated from that same code, so
187
188
  // segment casing and key casing always agree for real links.
188
- const filename = `${locale}/llms.txt`;
189
+ const filename = `${locale}/${file}`;
189
190
 
190
191
  if (!isIsrMode()) {
191
192
  const localPath = path.join(process.cwd(), 'public', filename);
@@ -199,7 +200,7 @@ export function createLocaleLlmsTxtHandler(): (
199
200
 
200
201
  const projectSlug = request.headers.get('x-project-slug');
201
202
  if (!projectSlug) {
202
- log('warn', 'Locale llms.txt request missing project slug');
203
+ log('warn', `Locale ${file} request missing project slug`);
203
204
  return new NextResponse('Project not found', { status: 404 });
204
205
  }
205
206
 
@@ -215,8 +216,8 @@ export function createLocaleLlmsTxtHandler(): (
215
216
  },
216
217
  });
217
218
  } catch (error) {
218
- log('error', 'Error serving locale llms.txt', { projectSlug, locale, error: String(error) });
219
- return new NextResponse('Error serving locale llms.txt', { status: 500 });
219
+ log('error', `Error serving locale ${file}`, { projectSlug, locale, error: String(error) });
220
+ return new NextResponse(`Error serving locale ${file}`, { status: 500 });
220
221
  }
221
222
  };
222
223
  }
@@ -56,7 +56,8 @@
56
56
  * keep serving one visitor's previewed HTML to everyone else regardless of
57
57
  * a `Vary` header.
58
58
  */
59
- import { getAllThemes, type ThemeName } from '@/themes';
59
+ import { getAllThemes, type ThemeConfig, type ThemeName } from '@/themes';
60
+ import { DUSK_PUBLIC } from '@/lib/dusk-embargo';
60
61
 
61
62
  /** Cookie the picker writes; read server-side in app/layout.tsx. */
62
63
  export const THEME_PREVIEW_COOKIE = 'jd_theme';
@@ -69,14 +70,21 @@ export const THEME_PREVIEW_SLUG = 'jamdesk-docs';
69
70
  // setting, and should not outlive the visit. The clear path still sends
70
71
  // `Max-Age=0` — see theme-preview-client.ts's applyThemePreview.
71
72
 
72
- // Built from the registry rather than using `isValidTheme`, which is a plain
73
- // `name in themes` check and therefore also returns true for inherited Object
74
- // keys ('constructor', 'toString'). Those would reach getTheme(), which falls
75
- // back to jam silently for unknown names but returns Object's own constructor
76
- // for 'constructor' — yielding data-theme="constructor" and no theme CSS.
77
- // A Set of the registry's own names is immune and picks up new themes for free.
73
+ /** Themes the picker offers. Dusk joins on launch day (lib/dusk-embargo.ts). */
74
+ export function getPreviewThemes(): ThemeConfig[] {
75
+ return getAllThemes().filter((theme) => DUSK_PUBLIC || theme.name !== 'dusk');
76
+ }
77
+
78
+ // Built from the themes the picker actually offers, rather than using
79
+ // `isValidTheme`, which is a plain `name in themes` check and therefore also
80
+ // returns true for inherited Object keys ('constructor', 'toString'). Those
81
+ // would reach getTheme(), which falls back to jam silently for unknown names
82
+ // but returns Object's own constructor for 'constructor' — yielding
83
+ // data-theme="constructor" and no theme CSS. A Set of the offered themes' own
84
+ // names is immune, picks up new themes for free, and keeps an embargoed theme
85
+ // out of both the picker and the cookie reader below.
78
86
  const VALID_THEME_NAMES: ReadonlySet<string> = new Set(
79
- getAllThemes().map((theme) => theme.name),
87
+ getPreviewThemes().map((theme) => theme.name),
80
88
  );
81
89
 
82
90
  /**
@@ -27,7 +27,7 @@ import { validateJwtAuth } from './jwt-auth-config.js';
27
27
  export interface DocsConfig {
28
28
  name: string;
29
29
  projectId?: string;
30
- theme?: 'jam' | 'nebula' | 'pulsar' | 'halo';
30
+ theme?: 'jam' | 'nebula' | 'pulsar' | 'halo' | 'dusk';
31
31
  navigation: NavigationConfig;
32
32
  integrations?: IntegrationsConfig;
33
33
  [key: string]: unknown;
@@ -170,7 +170,7 @@ export function parseConfig(content: string): DocsConfig {
170
170
  /**
171
171
  * Filter out noise from anyOf validation errors.
172
172
  *
173
- * The master schema uses a 4-branch anyOf (one per theme) and Ajv emits errors
173
+ * The master schema uses a 5-branch anyOf (one per theme) and Ajv emits errors
174
174
  * from every branch it tried, producing messages like `must be "nebula"` when
175
175
  * the user typed `theme: "jam"`. This helper:
176
176
  *
@@ -568,7 +568,7 @@ export async function validateConfig(
568
568
  }
569
569
 
570
570
  // Validate theme (case-insensitive — "MINT" still falls through to the Mintlify branch below).
571
- const validThemes = ['jam', 'nebula', 'pulsar', 'halo'];
571
+ const validThemes = ['jam', 'nebula', 'pulsar', 'halo', 'dusk'];
572
572
  if (typeof config.theme === 'string') {
573
573
  const themeLower = config.theme.toLowerCase();
574
574
  if (!validThemes.includes(themeLower)) {
@@ -582,7 +582,7 @@ export async function validateConfig(
582
582
  };
583
583
  }
584
584
  // Normalize so downstream Ajv schema (lowercase enum) accepts it.
585
- config.theme = themeLower as 'jam' | 'nebula' | 'pulsar' | 'halo';
585
+ config.theme = themeLower as 'jam' | 'nebula' | 'pulsar' | 'halo' | 'dusk';
586
586
  }
587
587
 
588
588
  if (legacyAnchors) {