@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.44-5337cc1

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 (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +42 -10
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -152,31 +152,3 @@ export function changelogTone(name: string): DuxtChangelogTone {
152
152
 
153
153
  return TONES[hash % TONES.length]!;
154
154
  }
155
-
156
- /**
157
- * A release day, in the reader's language.
158
- *
159
- * `timeZone: 'UTC'` because the value is a calendar date and not a moment: read
160
- * as local time, `2026-09-08` is the 7th for every reader west of Greenwich —
161
- * and a different day on the server than in the browser, which is a hydration
162
- * mismatch as well as a wrong date.
163
- *
164
- * Here rather than beside one of its callers because TWO of them draw it — the
165
- * timeline on the overview and the meta row on the release page — and a date
166
- * formatted in two places is two chances to print two different days for one
167
- * release.
168
- */
169
- export function changelogDate(
170
- value: string | undefined,
171
- locale: string
172
- ): string | undefined {
173
- if (!value) return undefined;
174
-
175
- const date = new Date(value);
176
- if (Number.isNaN(date.getTime())) return undefined;
177
-
178
- return new Intl.DateTimeFormat(locale, {
179
- dateStyle: 'medium',
180
- timeZone: 'UTC'
181
- }).format(date);
182
- }
@@ -52,9 +52,11 @@ const aliases: Record<string, string> = {
52
52
  curl: 'bash',
53
53
  js: 'typescript',
54
54
  javascript: 'typescript',
55
+ md: 'markdown',
55
56
  sh: 'bash',
56
57
  shell: 'bash',
57
- ts: 'typescript'
58
+ ts: 'typescript',
59
+ yml: 'yaml'
58
60
  };
59
61
 
60
62
  let highlighter: Promise<Highlighter> | undefined;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The picture beside a contributor's name, from the template a site configured.
3
+ *
4
+ * Here rather than beside either caller because TWO PLACES draw the same
5
+ * people — the meta column of a page (`DuxtPageInfo`) and the release history
6
+ * (`ChangelogReleases`) — and a template interpolated twice is two chances to
7
+ * show a face on one surface and a letter on the other.
8
+ *
9
+ * `duxt.contributors.avatarUrl` holds the whole URL with `{username}` in it, so
10
+ * a site pointing at a different forge, an avatar proxy or nothing at all is a
11
+ * config change rather than a component fork. UNDEFINED WHERE THERE IS NO
12
+ * HANDLE, which is the honest answer and not a placeholder: git only carries a
13
+ * username in a noreply address, and a guessed avatar is a picture of somebody
14
+ * else.
15
+ *
16
+ * A plain replacement, deliberately not an encoding pass: every caller here
17
+ * takes its username from `githubUsername`, which reads it out of an address
18
+ * GitHub itself issued.
19
+ */
20
+ export function contributorAvatar(
21
+ template: string | undefined,
22
+ username: string | undefined
23
+ ): string | undefined {
24
+ if (!template || !username) return undefined;
25
+
26
+ return template.replace('{username}', username);
27
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Which way round the interface is drawn.
3
+ *
4
+ * `auto` is deliberately absent. i18n lets a locale declare it, but it is a
5
+ * per-element CONTENT heuristic — the browser guessing at a run of text — and
6
+ * not a layout direction. Chrome cannot be mirrored on "it depends", so the
7
+ * resolver below collapses it to `ltr` rather than carrying a third state that
8
+ * nothing downstream could act on.
9
+ */
10
+ export type DuxtDirection = 'ltr' | 'rtl';
11
+
12
+ /** The shape i18n hands back from `locales`: objects, or bare codes. */
13
+ export type DuxtLocaleEntry =
14
+ | string
15
+ | { code: string; dir?: 'ltr' | 'rtl' | 'auto' };
16
+
17
+ /**
18
+ * The direction the active locale declares, or left-to-right.
19
+ *
20
+ * THE DECLARATION IS THE ONLY SOURCE. The layer does not keep a list of
21
+ * right-to-left languages and does not guess from a script or a region: a
22
+ * locale is right-to-left because its entry says `dir: 'rtl'`, which is the
23
+ * one place a consumer adding a language can state it. Guessing would mean a
24
+ * consumer's own locale silently rendering mirrored — or, worse, silently not.
25
+ *
26
+ * Shared by `app.vue` and `error.vue` on purpose. The error page REPLACES the
27
+ * app shell rather than nesting inside it, so a direction computed inline in
28
+ * one of them is a direction the other does not have — which is precisely how
29
+ * `error.vue` came to set `lang` and never `dir`.
30
+ */
31
+ export function localeDirection(
32
+ locales: readonly DuxtLocaleEntry[],
33
+ active: string
34
+ ): DuxtDirection {
35
+ const entry = locales.find(
36
+ (candidate) =>
37
+ (typeof candidate === 'string' ? candidate : candidate.code) === active
38
+ );
39
+
40
+ if (typeof entry !== 'object' || !entry) return 'ltr';
41
+
42
+ return entry.dir === 'rtl' ? 'rtl' : 'ltr';
43
+ }
@@ -19,12 +19,55 @@ export const duxtDefaults: DuxtConfig = {
19
19
  // until a consumer sets its own. It names nobody, which is the whole test.
20
20
  title: 'duxt.defaults.title',
21
21
 
22
+ copy: {
23
+ models: [
24
+ {
25
+ label: 'duxt.page.copy.chatgpt',
26
+ icon: 'simple-icons:openai',
27
+ url: 'https://chatgpt.com/?q='
28
+ },
29
+ {
30
+ label: 'duxt.page.copy.claude',
31
+ icon: 'simple-icons:claude',
32
+ url: 'https://claude.ai/new?q='
33
+ }
34
+ ]
35
+ },
36
+ contributors: { avatarUrl: 'https://github.com/{username}.png?size=40' },
37
+ toc: { depth: 3, scrollOffset: 96 },
38
+ search: {
39
+ fuzzy: { threshold: 0.35, minMatchCharLength: 3, limit: 20 },
40
+ recentPages: 5
41
+ },
42
+ openapi: { exampleDepth: 6, schemaDepth: 8 },
43
+ // A policy, not a binding table. WHICH keys duxt listens to is the layer's
44
+ // and lives in `duxtShortcuts`; whether the unmodified ones are live at all
45
+ // is the site's, because only a site knows what else its pages put under a
46
+ // bare keystroke. One boolean rather than four rewritable rows: a consumer
47
+ // that could move `?` would have to move it in the handler, the sheet and
48
+ // the guards at once, and those three drifting apart is what this replaces.
49
+ shortcuts: { singleCharacter: true },
50
+
22
51
  // No `version`. The layer knows nothing about the state of somebody else's
23
52
  // project, and a default made every site that extended it wear a number it
24
53
  // had never set — in the hero pill and, for a site with at most one source,
25
54
  // in the header badge beside the search. A site that has a version says so
26
55
  // in its own `app.config.ts`; `DuxtVersion` draws nothing until it does.
27
56
 
57
+ /**
58
+ * Empty, like `sections` and `links` below.
59
+ *
60
+ * An announcement is the SITE's own sentence — a release, a migration window,
61
+ * an outage — and a layer that shipped one would put its words at the top of
62
+ * every page extending it. Left empty the strip draws nothing at all: no
63
+ * banner, and no space held open for one.
64
+ *
65
+ * Written out rather than left absent so the key appears in the devtools
66
+ * Config panel, where a consumer meets it without reading the reference
67
+ * first — the same reason `sections` and `links` are spelled out below.
68
+ */
69
+ announcements: [],
70
+
28
71
  navigation: [
29
72
  // No `to`: the header resolves it to the first section, so the entry works
30
73
  // whether or not the consumer's URLs carry a prefix.
@@ -38,6 +38,16 @@ const TEXT_KEYS = new Set([
38
38
  // JSON on the page, which is exactly how this one was found.
39
39
  'highlightsTitle',
40
40
  'label',
41
+ // A NAME, never an address. A source's display name and
42
+ // `duxt.organization.name` are both prose a site may translate; the strings
43
+ // beside them a reader might mistake for names — `collection`, `prefix`,
44
+ // `repo`, `slug` — are absent from this list and stay untouched, which is
45
+ // the whole reason it is an allowlist rather than a walk.
46
+ 'name',
47
+ // An announcement's sentence. Neither `label` nor `description`, because it
48
+ // is the whole of what the banner says rather than a caption on something
49
+ // else — and it needs the line here for the same reason the entry above does.
50
+ 'text',
41
51
  'title'
42
52
  ]);
43
53
 
@@ -0,0 +1,28 @@
1
+ /**
2
+ * A date, in the reader's language — or nothing, where there is no readable one.
3
+ *
4
+ * One formatter for every date the theme prints, because a date formatted in
5
+ * two places is two chances to print two different days for one fact.
6
+ *
7
+ * `calendarDay` is the one decision a caller makes, and it is about what the
8
+ * value IS. A release date is a calendar day and not a moment: read as local
9
+ * time, `2026-09-08` is the 7th for every reader west of Greenwich — and a
10
+ * different day on the server than in the browser, which is a hydration
11
+ * mismatch as well as a wrong date — so it is pinned to UTC. A commit date IS a
12
+ * moment, and is left in the reader's own zone.
13
+ */
14
+ export function formatDate(
15
+ value: string | undefined,
16
+ locale: string,
17
+ { calendarDay = false }: { calendarDay?: boolean } = {}
18
+ ): string | undefined {
19
+ if (!value) return undefined;
20
+
21
+ const date = new Date(value);
22
+ if (Number.isNaN(date.getTime())) return undefined;
23
+
24
+ return new Intl.DateTimeFormat(locale, {
25
+ dateStyle: 'medium',
26
+ ...(calendarDay ? { timeZone: 'UTC' } : {})
27
+ }).format(date);
28
+ }
@@ -65,6 +65,7 @@ export function withGeneratedSections(
65
65
  // answered for the consumer's list only.
66
66
  const taken = new Set(existing.map((entry) => entry.to).filter(Boolean));
67
67
  const entries: DuxtLink[] = [];
68
+ const listed: DuxtResolvedSource[][] = [];
68
69
 
69
70
  for (const declaration of declarations) {
70
71
  const meta = declaration[0]!.generated!;
@@ -74,7 +75,10 @@ export function withGeneratedSections(
74
75
  // the one this reader would be given: a section listed by hand is listed,
75
76
  // and the entry `to` resolves to is always one of these prefixes, so this
76
77
  // is the de-dupe by URL as well.
77
- if (declaration.some((entry) => taken.has(entry.prefix))) continue;
78
+ if (declaration.some((entry) => taken.has(entry.prefix))) {
79
+ listed.push(declaration);
80
+ continue;
81
+ }
78
82
 
79
83
  const to = entryPath(declaration, base);
80
84
 
@@ -82,27 +86,79 @@ export function withGeneratedSections(
82
86
  entries.push({ label: meta.label, to, icon: meta.icon });
83
87
  }
84
88
 
85
- return entries;
89
+ return { entries, moved: followReader(existing, listed, base) };
86
90
  };
87
91
 
88
92
  const navigation = entriesFor('navigation', config.navigation ?? []);
89
93
  const sections = entriesFor('sections', config.sections ?? []);
90
94
 
91
- if (!navigation.length && !sections.length) return config;
95
+ if (
96
+ !navigation.entries.length &&
97
+ !sections.entries.length &&
98
+ !navigation.moved &&
99
+ !sections.moved
100
+ )
101
+ return config;
92
102
 
93
103
  return {
94
104
  ...config,
95
- // Only the row that gains something is rewritten: an empty array where the
96
- // config had `undefined` is a different value, and the header reads both.
97
- ...(navigation.length
98
- ? { navigation: [...(config.navigation ?? []), ...navigation] }
105
+ // Only the row that gains or moves something is rewritten: an empty array
106
+ // where the config had `undefined` is a different value, and the header
107
+ // reads both.
108
+ ...(navigation.entries.length || navigation.moved
109
+ ? {
110
+ navigation: [
111
+ ...(navigation.moved ?? config.navigation ?? []),
112
+ ...navigation.entries
113
+ ]
114
+ }
99
115
  : {}),
100
- ...(sections.length
101
- ? { sections: [...(config.sections ?? []), ...sections] }
116
+ ...(sections.entries.length || sections.moved
117
+ ? {
118
+ sections: [
119
+ ...(sections.moved ?? config.sections ?? []),
120
+ ...sections.entries
121
+ ]
122
+ }
102
123
  : {})
103
124
  };
104
125
  }
105
126
 
127
+ /**
128
+ * A section listed by hand keeps its POSITION, and its URL follows the reader.
129
+ *
130
+ * Placing an entry by hand is how a consumer orders the row, and a `per-version`
131
+ * section written at the default version's URL would otherwise send a reader on
132
+ * `v2.x` to `v3.x` the moment they clicked it. So an entry whose `to` is one of
133
+ * the declaration's own prefixes is pointed at the edition the reader is on —
134
+ * the same answer the appended entry gets. An entry pointing anywhere else
135
+ * inside the section is a page the consumer chose, and stays where it points.
136
+ *
137
+ * `undefined` when nothing moved, so an untouched row keeps its identity.
138
+ */
139
+ function followReader(
140
+ existing: DuxtLink[],
141
+ listed: DuxtResolvedSource[][],
142
+ base: string
143
+ ): DuxtLink[] | undefined {
144
+ let changed = false;
145
+
146
+ const moved = existing.map((entry) => {
147
+ const declaration = listed.find((candidate) =>
148
+ candidate.some((source) => source.prefix === entry.to)
149
+ );
150
+ if (!declaration) return entry;
151
+
152
+ const to = entryPath(declaration, base);
153
+ if (to === entry.to) return entry;
154
+
155
+ changed = true;
156
+ return { ...entry, to };
157
+ });
158
+
159
+ return changed ? moved : undefined;
160
+ }
161
+
106
162
  /**
107
163
  * The manifest's generated entries, grouped into the declarations that made
108
164
  * them, in the order the site declared them.
@@ -171,11 +227,11 @@ const depth = (prefix: string) => (prefix ? prefix.split('/').length - 1 : 0);
171
227
  *
172
228
  * THE SHARED SLOT. Each type names its own layout and every type renders
173
229
  * through this one lookup, which is what stops two types inventing two
174
- * unrelated ways to escape the docs chrome. A type that names none renders in
175
- * the ordinary chrome, and the lookup answers `undefined`.
230
+ * unrelated ways to escape the docs layout. A type that names none renders in
231
+ * the ordinary docs layout, and the lookup answers `undefined`.
176
232
  *
177
233
  * Filtered to the sections that name a layout before the prefix match, so a
178
- * site whose types all render in the docs chrome does no work at all.
234
+ * site whose types all render in the docs layout does no work at all.
179
235
  */
180
236
  export function generatedLayout(
181
237
  path: string,
@@ -24,7 +24,7 @@ export interface DuxtGeneratedTocLink {
24
24
  children?: DuxtGeneratedTocLink[];
25
25
  }
26
26
 
27
- const HEADING = /^h([23])$/;
27
+ const HEADING = /^h([2-6])$/;
28
28
 
29
29
  /**
30
30
  * A prop, whichever spelling it arrived in.
@@ -63,7 +63,11 @@ function text(node: unknown): string {
63
63
  * promoted to `##` by the parser — so inside a group it is one level further
64
64
  * in, or the column would claim both are siblings.
65
65
  */
66
- function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
66
+ function walk(
67
+ nodes: unknown[],
68
+ inside: boolean,
69
+ maximumDepth: number
70
+ ): DuxtGeneratedTocLink[] {
67
71
  const links: DuxtGeneratedTocLink[] = [];
68
72
 
69
73
  for (const node of nodes) {
@@ -84,7 +88,7 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
84
88
  // The same words the heading itself draws — the anchor is still built
85
89
  // from the file's own name, so the two cannot point apart.
86
90
  if (id) links.push({ id, text: changelogLabel(name), depth: 2 });
87
- links.push(...walk(children, true));
91
+ links.push(...walk(children, true, maximumDepth));
88
92
  continue;
89
93
  }
90
94
 
@@ -96,7 +100,7 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
96
100
  if (tag === 'changelog-releases') continue;
97
101
 
98
102
  const heading = HEADING.exec(tag);
99
- if (heading) {
103
+ if (heading && Number(heading[1]) <= maximumDepth) {
100
104
  const id = props.id;
101
105
  if (typeof id === 'string' && id) {
102
106
  links.push({
@@ -108,20 +112,23 @@ function walk(nodes: unknown[], inside: boolean): DuxtGeneratedTocLink[] {
108
112
  continue;
109
113
  }
110
114
 
111
- links.push(...walk(children, inside));
115
+ links.push(...walk(children, inside, maximumDepth));
112
116
  }
113
117
 
114
118
  return links;
115
119
  }
116
120
 
117
121
  /** The flat list nested the way `DuxtToc` draws it: one level of children. */
118
- export function generatedToc(body: unknown): DuxtGeneratedTocLink[] {
122
+ export function generatedToc(
123
+ body: unknown,
124
+ maximumDepth = 3
125
+ ): DuxtGeneratedTocLink[] {
119
126
  const value = (body as { value?: unknown })?.value;
120
127
  if (!Array.isArray(value)) return [];
121
128
 
122
129
  const links: DuxtGeneratedTocLink[] = [];
123
130
 
124
- for (const link of walk(value, false)) {
131
+ for (const link of walk(value, false, maximumDepth)) {
125
132
  const parent = links.at(-1);
126
133
 
127
134
  if (link.depth > 2 && parent) (parent.children ??= []).push(link);
@@ -21,6 +21,22 @@ export function stripLocalePrefix(
21
21
  return documentPath.replace(/\/+$/, '') || (documentPath ? '/' : '');
22
22
  }
23
23
 
24
+ /**
25
+ * The locale CODES out of an i18n locale list.
26
+ *
27
+ * `@nuxtjs/i18n` accepts a bare code or an object carrying one, and every place
28
+ * that strips a prefix — the composable, the layout middleware, the devtools
29
+ * path debugger — needs the plain list. Absent is an empty list: a site without
30
+ * the module has no prefixes to strip.
31
+ */
32
+ export function localeCodesOf(
33
+ locales: readonly (string | { code: string })[] | undefined
34
+ ): string[] {
35
+ return (locales ?? []).map((entry) =>
36
+ typeof entry === 'string' ? entry : entry.code
37
+ );
38
+ }
39
+
24
40
  /**
25
41
  * The same split, keeping the half that was thrown away.
26
42
  *
@@ -46,3 +62,30 @@ export function splitLocalePath(
46
62
 
47
63
  return { path };
48
64
  }
65
+
66
+ /**
67
+ * The routes a MACHINE reads, which exist once for the whole site.
68
+ *
69
+ * `llms.txt`, `llms-full.txt`, `rss.xml` and the MCP server are server routes
70
+ * at the root. They are not pages, `@nuxtjs/i18n` knows nothing of them, and
71
+ * `localePath('/llms.txt')` therefore hands back `/de-DE/llms.txt` — a path
72
+ * that falls through to the catch-all page and 404s. That is what the landing
73
+ * page's framed `llms.txt` did in every locale: Nitro's error response carries
74
+ * `X-Frame-Options: DENY`, so the reader saw a broken frame rather than a 404.
75
+ *
76
+ * An explicit list, not "has a file extension": a version segment such as
77
+ * `v0.1.0` reads as one. And `…/page.md` is deliberately NOT on it — the
78
+ * raw-markdown middleware splits the locale off and serves the translation,
79
+ * so a localised `.md` link is the right link.
80
+ *
81
+ * Mirrors the root rows of `DEPLOYMENT_ROUTES` in `scripts/check-routes.ts`.
82
+ */
83
+ const MACHINE_ROUTES = ['/llms.txt', '/llms-full.txt', '/rss.xml', '/mcp'];
84
+
85
+ export function isMachineRoute(path: string): boolean {
86
+ const bare = path.split(/[?#]/)[0]!.replace(/\/+$/, '');
87
+
88
+ return MACHINE_ROUTES.some(
89
+ (route) => bare === route || (route === '/mcp' && bare.startsWith('/mcp/'))
90
+ );
91
+ }
@@ -1,5 +1,21 @@
1
1
  import type { ContentNavigationItem } from '@nuxt/content';
2
2
 
3
+ /** Pages in reading order, with non-page groups expanded into their members. */
4
+ export function flattenedNavigationPages(
5
+ items: ContentNavigationItem[]
6
+ ): ContentNavigationItem[] {
7
+ const pages: ContentNavigationItem[] = [];
8
+
9
+ for (const item of items) {
10
+ if (item.page !== false) pages.push(item);
11
+ if (item.children?.length) {
12
+ pages.push(...flattenedNavigationPages(item.children));
13
+ }
14
+ }
15
+
16
+ return pages;
17
+ }
18
+
3
19
  /** Actual pages, including dotted version paths; folder wrappers are not pages. */
4
20
  export function navigationPagePaths(items: ContentNavigationItem[]): string[] {
5
21
  const paths = new Set<string>();
@@ -90,6 +106,14 @@ export function sectionItems(
90
106
  * Anything at or above the source's own prefix is one of those wrapper nodes,
91
107
  * not a page anyone navigates to — it showed up as an extra crumb on a
92
108
  * versioned URL that the unversioned one did not have.
109
+ *
110
+ * A `page: false` node on the chain is the same thing one level down: a group
111
+ * that exists to hold pages and has no route of its own — tfplugindocs'
112
+ * `subcategory` groups are the standing example, synthesised from frontmatter
113
+ * with no directory behind them. The walk still goes THROUGH such a node to
114
+ * reach the page; only the returned trail drops it, because `DuxtBreadcrumb`
115
+ * links every crumb but the last and `useDuxtBreadcrumb` emits the same trail
116
+ * as `BreadcrumbList` JSON-LD — so a group left in ships a 404 twice over.
93
117
  */
94
118
  export function trailBelowPrefix(
95
119
  tree: ContentNavigationItem[],
@@ -118,7 +142,33 @@ export function trailBelowPrefix(
118
142
 
119
143
  walk(tree, []);
120
144
 
121
- return found.filter((item) => (item.path?.length ?? 0) > prefix.length);
145
+ return found.filter(
146
+ (item) => item.page !== false && (item.path?.length ?? 0) > prefix.length
147
+ );
148
+ }
149
+
150
+ /**
151
+ * The children a `::page-cards` block draws for a branch.
152
+ *
153
+ * The branch's own index carries the branch's path — Content emits it as a
154
+ * child — and is the page the cards sit on, so it is never a card.
155
+ *
156
+ * A `page: false` group is replaced by its members rather than dropped: the
157
+ * group has no route to link to, but the pages under it are exactly what the
158
+ * reader came for. `flattenedNavigationPages` is not the same thing and cannot
159
+ * stand in — it descends through real folders too, and a card for a folder is
160
+ * the point of a card grid.
161
+ */
162
+ export function navigationCardItems(
163
+ branch: ContentNavigationItem | undefined,
164
+ base: string
165
+ ): ContentNavigationItem[] {
166
+ const expand = (items: ContentNavigationItem[]): ContentNavigationItem[] =>
167
+ items.flatMap((item) =>
168
+ item.page === false ? expand(item.children ?? []) : [item]
169
+ );
170
+
171
+ return expand(branch?.children ?? []).filter((child) => child.path !== base);
122
172
  }
123
173
 
124
174
  /**
@@ -19,8 +19,16 @@ export function nearestPages(
19
19
  const target = segments(wanted);
20
20
  if (!target.length) return [];
21
21
 
22
+ // ONE ROW PER PATH. A folder's index and the folder itself are both in the
23
+ // navigation under the same route, and the list offered the reader
24
+ // `Concepts /v0.2.0/concepts` twice.
25
+ const seen = new Set<string>();
26
+
22
27
  return candidates
23
28
  .filter((candidate) => candidate.path && candidate.path !== wanted)
29
+ .filter(
30
+ (candidate) => !seen.has(candidate.path) && seen.add(candidate.path)
31
+ )
24
32
  .map((candidate) => ({
25
33
  candidate,
26
34
  score: score(target, segments(candidate.path))
@@ -21,8 +21,6 @@ import type {
21
21
  } from '../../openapi-model';
22
22
 
23
23
  /** How deep an example is derived before it is called recursive enough. */
24
- const MAX_DEPTH = 6;
25
-
26
24
  /**
27
25
  * A schema's type, as the one word a table cell has room for.
28
26
  *
@@ -111,9 +109,10 @@ export function excluded(
111
109
  export function openApiExampleValue(
112
110
  schema: DuxtOpenApiSchema | undefined,
113
111
  direction: DuxtOpenApiDirection,
114
- depth = 0
112
+ depth = 0,
113
+ maximumDepth = 6
115
114
  ): unknown {
116
- if (!schema || schema.circular || depth > MAX_DEPTH) return null;
115
+ if (!schema || schema.circular || depth > maximumDepth) return null;
117
116
 
118
117
  if (schema.examples?.length) return schema.examples[0];
119
118
  if (schema.default !== undefined) return schema.default;
@@ -121,7 +120,8 @@ export function openApiExampleValue(
121
120
  if (schema.enum?.length) return schema.enum[0];
122
121
 
123
122
  const branch = schema.oneOf?.[0] ?? schema.anyOf?.[0];
124
- if (branch) return openApiExampleValue(branch, direction, depth + 1);
123
+ if (branch)
124
+ return openApiExampleValue(branch, direction, depth + 1, maximumDepth);
125
125
 
126
126
  const declared = (schema.types ?? []).filter((type) => type !== 'null');
127
127
  const type =
@@ -149,24 +149,29 @@ export function openApiExampleValue(
149
149
  built[property.name] = openApiExampleValue(
150
150
  property.schema,
151
151
  direction,
152
- depth + 1
152
+ depth + 1,
153
+ maximumDepth
153
154
  );
154
155
  }
155
156
 
156
157
  for (const merged of schema.allOf ?? []) {
157
158
  Object.assign(
158
159
  built,
159
- openApiExampleValue(merged, direction, depth + 1) as Record<
160
- string,
161
- unknown
162
- >
160
+ openApiExampleValue(
161
+ merged,
162
+ direction,
163
+ depth + 1,
164
+ maximumDepth
165
+ ) as Record<string, unknown>
163
166
  );
164
167
  }
165
168
 
166
169
  return built;
167
170
  }
168
171
  case 'array':
169
- return [openApiExampleValue(schema.items, direction, depth + 1)];
172
+ return [
173
+ openApiExampleValue(schema.items, direction, depth + 1, maximumDepth)
174
+ ];
170
175
  case 'boolean':
171
176
  return true;
172
177
  case 'integer':