@kirchdev/duxt 0.3.4-dev-main.41-23ded41 → 0.3.5-dev-main.43-3310f4b

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 +41 -9
  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
@@ -0,0 +1,248 @@
1
+ import { asText } from './duxt-text';
2
+
3
+ /**
4
+ * How a search hit says where it came from.
5
+ *
6
+ * The search used to label a hit `[repo, version].join(' ') || prefix || '/'`,
7
+ * which is a URL segment doing a name's job: the site's own documentation sits
8
+ * at the root and therefore showed a bare `/`, and a source whose segment is an
9
+ * abbreviation showed the abbreviation. Neither tells a reader which project
10
+ * they are about to open.
11
+ *
12
+ * So provenance is resolved to a NAME here, once, off the same manifest the
13
+ * rest of the theme reads — and `sources[].name` lets a consumer state the one
14
+ * thing no rule can derive.
15
+ */
16
+
17
+ /** Where one hit came from, as the dialog says it. */
18
+ export interface DuxtSearchProvenance {
19
+ /** The source's display name — see `sourceDisplayNames`. */
20
+ name: string;
21
+ /** The generated artefact's own label, where the hit came out of one. */
22
+ artefact?: string;
23
+ /** The edition, where the site publishes more than one. */
24
+ version?: string;
25
+ }
26
+
27
+ /** One search result, and whether it opens a run of shared provenance. */
28
+ export interface DuxtSearchRow<T> {
29
+ hit: T;
30
+ /**
31
+ * Drawn above this hit, once per run. Decorative: the same text reaches a
32
+ * screen reader through `provenance` on every row, so the visible caption is
33
+ * `aria-hidden` and never read twice.
34
+ */
35
+ caption?: string;
36
+ /** This hit's own provenance, whether or not it drew the caption. */
37
+ provenance?: string;
38
+ }
39
+
40
+ /** `Demo · Demo API · v3.x` — source, artefact, edition, in that order. */
41
+ export const provenanceLabel = (source: DuxtSearchProvenance) =>
42
+ [source.name, source.artefact, source.version].filter(Boolean).join(' · ');
43
+
44
+ /**
45
+ * Results, with the repeated source label lifted off them.
46
+ *
47
+ * A badge on every row spelled the same source name five times down a list of
48
+ * five hits from one repository — the noise this issue is about. A caption at
49
+ * the head of each RUN says it once.
50
+ *
51
+ * A run is CONTIGUOUS, which is the whole constraint. Gathering every hit of
52
+ * one source under a single heading would re-sort the list, and the ranking is
53
+ * the thing the merge across sources exists to produce — see `useDuxtSearch`.
54
+ * So provenance that comes back changes nothing about order: it simply opens a
55
+ * second run.
56
+ */
57
+ export function searchRows<T extends { source?: DuxtSearchProvenance }>(
58
+ hits: T[]
59
+ ): DuxtSearchRow<T>[] {
60
+ let previous: string | undefined;
61
+
62
+ return hits.map((hit) => {
63
+ const provenance = hit.source ? provenanceLabel(hit.source) : undefined;
64
+ const caption =
65
+ provenance && provenance !== previous ? provenance : undefined;
66
+ previous = provenance;
67
+
68
+ return { hit, caption, provenance };
69
+ });
70
+ }
71
+
72
+ /** How much of a section's text a preview may show. */
73
+ const EXCERPT_LENGTH = 160;
74
+
75
+ /**
76
+ * A plain-text preview of the section a hit sits in.
77
+ *
78
+ * A title and a breadcrumb say where a result IS; they do not say whether it
79
+ * answers the question, and that is the judgement a reader makes before opening
80
+ * a page. So each hit carries a line or two of its own text.
81
+ *
82
+ * Whitespace is collapsed because Content's index stores a section as it was
83
+ * written — newlines, indentation and all — and an excerpt taken raw would
84
+ * spend half its budget on layout the reader cannot see.
85
+ *
86
+ * PLAIN TEXT, and it stays that way. What comes back is the section's
87
+ * characters, `<` and `&` included; the component renders it through
88
+ * interpolation, never `v-html`, so a document that happens to contain markup
89
+ * shows markup rather than running it.
90
+ */
91
+ export function searchExcerpt(
92
+ content: string | undefined,
93
+ term: string,
94
+ max = EXCERPT_LENGTH
95
+ ): string {
96
+ const text = (content ?? '').replace(/\s+/g, ' ').trim();
97
+ if (!text) return '';
98
+ if (text.length <= max) return text;
99
+
100
+ // The window is anchored on the LITERAL term where the text carries it: a
101
+ // preview whose match is off the end is a preview of the wrong sentence. FTS
102
+ // and Fuse both match things a literal search does not, so this often finds
103
+ // nothing — and the opening of a section is a good second answer, because a
104
+ // section's first sentence is usually what it is about.
105
+ const found = term.trim()
106
+ ? text.toLowerCase().indexOf(term.trim().toLowerCase())
107
+ : -1;
108
+
109
+ if (found < 0) return `${cut(text, 0, max)}…`;
110
+
111
+ // A third of the window ahead of the match, so the reader sees what leads
112
+ // into it as well as what follows.
113
+ const lead = Math.floor(max / 3);
114
+ const start = Math.max(0, found - lead);
115
+ const end = Math.min(text.length, start + max);
116
+
117
+ return [
118
+ start > 0 ? '…' : '',
119
+ text.slice(start > 0 ? boundary(text, start) : 0, end).trim(),
120
+ end < text.length ? '…' : ''
121
+ ].join('');
122
+ }
123
+
124
+ /** Forward to the next word start, so a window never opens mid-word. */
125
+ function boundary(text: string, index: number) {
126
+ const space = text.indexOf(' ', index);
127
+ return space < 0 || space > index + 20 ? index : space + 1;
128
+ }
129
+
130
+ /** Back to the last whole word that fits, so a cut never splits one. */
131
+ function cut(text: string, start: number, max: number) {
132
+ const slice = text.slice(start, start + max);
133
+ const space = slice.lastIndexOf(' ');
134
+ return (space > max / 2 ? slice.slice(0, space) : slice).trim();
135
+ }
136
+
137
+ /** As much of a resolved source as naming it needs. */
138
+ export interface DuxtSearchSourceInput {
139
+ collection: string;
140
+ /** The display name a consumer configured, already resolved for the locale. */
141
+ name?: DuxtText;
142
+ /** The source's own URL segment — its identity, where it has one. */
143
+ repo?: string;
144
+ /** `owner/name` of the repository behind it, where there is one. */
145
+ repository?: string;
146
+ /** Folder inside that repository holding the Markdown. */
147
+ path: string;
148
+ }
149
+
150
+ /**
151
+ * A slug as a reader would write it: `client-sdk` → `Client Sdk`.
152
+ *
153
+ * Deliberately not a title-caser — `of`, `and` and the rest are left capitalised
154
+ * rather than guessed at per language, because this runs over a URL segment and
155
+ * a wrong small word reads worse than a consistent one. A consumer who wants
156
+ * the real spelling writes `name`.
157
+ */
158
+ export const humanizeSlug = (value: string) =>
159
+ value
160
+ .split(/[-_.\s]+/)
161
+ .filter(Boolean)
162
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
163
+ .join(' ');
164
+
165
+ /**
166
+ * The name each collection shows, by collection.
167
+ *
168
+ * The ladder, in order, and every rung is a fallback for the one before it:
169
+ *
170
+ * 1. `sources[].name` — display only, and the only rung a consumer controls.
171
+ * 2. The source's own SEGMENT, humanized. Usually this IS the repository's
172
+ * name: `repoSlug` derives the segment from it unless the source claimed a
173
+ * `slug`, and where a source did claim one, the claim is the deliberate
174
+ * spelling and the better name.
175
+ * 3. The site's name — the answer for a source with no segment at all, which
176
+ * is the site's own root documentation. Unset, `duxt.title` is already the
177
+ * localized "Documentation" label, so that is the bottom rung and it needs
178
+ * no second string to translate.
179
+ *
180
+ * The REPOSITORY deliberately names nothing. Several sources may be read out of
181
+ * one repository — this site's own demo tree and its documentation are — so a
182
+ * repository identifies a source only sometimes, and a rule that is right only
183
+ * sometimes attributes one section to another. It qualifies a collision below,
184
+ * where it has a second name beside it to be read against, and nothing else.
185
+ *
186
+ * A collection never falls through to its own identifier or to `/`: both are
187
+ * addresses, and this is the one place that must not show one.
188
+ *
189
+ * Two DIFFERENT sources landing on one name are then qualified — see
190
+ * `qualified`. Two editions of the SAME source landing on one name are the
191
+ * point of the exercise and left alone: a version and a translation are the
192
+ * same project, and the version caption already tells them apart.
193
+ */
194
+ export function sourceDisplayNames(
195
+ sources: DuxtSearchSourceInput[],
196
+ siteName: string
197
+ ): Map<string, string> {
198
+ const named = sources.map((source) => ({
199
+ source,
200
+ // The source's identity, not the collection's: every version and every
201
+ // language of one source shares it, which is what keeps them one name.
202
+ identity: source.repo ?? '',
203
+ name:
204
+ asText(source.name) ||
205
+ (source.repo && humanizeSlug(source.repo)) ||
206
+ siteName
207
+ }));
208
+
209
+ const identities = new Map<string, Set<string>>();
210
+ for (const { name, identity } of named) {
211
+ const claimed = identities.get(name) ?? new Set<string>();
212
+ claimed.add(identity);
213
+ identities.set(name, claimed);
214
+ }
215
+
216
+ return new Map(
217
+ named.map(({ source, name }) => [
218
+ source.collection,
219
+ identities.get(name)!.size > 1 ? qualified(name, source, named) : name
220
+ ])
221
+ );
222
+ }
223
+
224
+ /**
225
+ * One colliding name, told apart.
226
+ *
227
+ * The repository first, because "Reference (acme/api)" answers the reader's
228
+ * actual question — which project is this. A monorepo publishing two sections
229
+ * out of one repository cannot be told apart that way, so the folder is the
230
+ * second rung: it is the only thing left that differs, and it is readable.
231
+ */
232
+ function qualified(
233
+ name: string,
234
+ source: DuxtSearchSourceInput,
235
+ named: { source: DuxtSearchSourceInput; identity: string; name: string }[]
236
+ ) {
237
+ const colliding = named.filter((entry) => entry.name === name);
238
+ const repositories = new Set(
239
+ colliding.map((entry) => entry.source.repository ?? '')
240
+ );
241
+
242
+ const qualifier =
243
+ repositories.size > 1 && source.repository
244
+ ? source.repository
245
+ : source.path;
246
+
247
+ return qualifier ? `${name} (${qualifier})` : name;
248
+ }
@@ -50,15 +50,65 @@ function documentationFor(source: Area | undefined, sources: Area[]) {
50
50
  )[0];
51
51
  }
52
52
 
53
- /** The version-neutral root of a documentation area. */
53
+ /**
54
+ * The version-neutral root of a documentation area.
55
+ *
56
+ * A tree published INSIDE another area's root belongs to that area: a provider
57
+ * reference at `/demo/terraform` is a part of `/demo` in the same way the
58
+ * generated reference beside it is, and treating it as an area of its own
59
+ * would empty the row on its pages and hide its entry on every other one. The
60
+ * root area `''` never adopts anything, or every source would be one area.
61
+ */
54
62
  function areaRoot(source: Area | undefined, sources: Area[]): string {
63
+ const own = treeRoot(source, sources);
64
+ if (!own) return own;
65
+
66
+ const enclosing = sources
67
+ .filter((other) => !other.generated)
68
+ .map((other) => treeRoot(other, sources))
69
+ .filter((root) => root && root !== own && isInside(own, root))
70
+ .sort((a, b) => a.length - b.length)[0];
71
+
72
+ return enclosing ?? own;
73
+ }
74
+
75
+ /**
76
+ * The prefix a source WITHOUT a slug has before its version segment.
77
+ *
78
+ * Such a source carries no identity to group its editions by, so they used to
79
+ * keep their own prefixes: `/v0.2.0` was an area apart from the root, and on a
80
+ * site with a second area every section was filtered off the page — the row
81
+ * vanished on every non-default edition of the documentation it belongs to.
82
+ * The version segment is the one thing the editions of one source add to the
83
+ * same base, so it is taken off again — but only where an edition without it
84
+ * actually exists, so a prefix that merely ends like a version stays its own.
85
+ */
86
+ function unversioned(source: Area, sources: Area[]): string {
87
+ const segment = source.version ? `/${source.version}` : '';
88
+ if (!segment || !source.prefix.endsWith(segment)) return source.prefix;
89
+
90
+ const base = source.prefix.slice(0, -segment.length);
91
+
92
+ return sources.some(
93
+ (other) =>
94
+ !other.generated &&
95
+ !other.repo &&
96
+ other.prefix === base &&
97
+ other.version !== source.version
98
+ )
99
+ ? base
100
+ : source.prefix;
101
+ }
102
+
103
+ /** The root of the one documentation tree a source belongs to. */
104
+ function treeRoot(source: Area | undefined, sources: Area[]): string {
55
105
  const documentation = documentationFor(source, sources);
56
106
  if (!documentation) return '';
57
107
 
58
108
  // A slug is the source identity and survives the version segment. All
59
109
  // versions of `/demo` are therefore one area, whose shallowest prefix is
60
- // the default edition. Sources without an identity retain their own prefix.
61
- if (!documentation.repo) return documentation.prefix;
110
+ // the default edition.
111
+ if (!documentation.repo) return unversioned(documentation, sources);
62
112
 
63
113
  return sources
64
114
  .filter((other) => !other.generated && other.repo === documentation.repo)
@@ -96,6 +146,17 @@ export function areaForPath(path: string, sources: Area[]): string {
96
146
  return areaRoot(sourceForPath(path, sources), sources);
97
147
  }
98
148
 
149
+ /**
150
+ * Whether two sources are editions of one documentation tree: the same slug
151
+ * where they carry one, the same unversioned root where they do not. Two
152
+ * slug-less sources are NOT one tree merely for both lacking a slug.
153
+ */
154
+ function sameTree(a: Area, b: Area, sources: Area[]): boolean {
155
+ if (a.repo || b.repo) return a.repo === b.repo;
156
+
157
+ return unversioned(a, sources) === unversioned(b, sources);
158
+ }
159
+
99
160
  /**
100
161
  * The entries of one area, in the order the site declared them.
101
162
  *
@@ -134,7 +195,7 @@ export function sectionsForPath<T extends { to?: string }>(
134
195
  if (
135
196
  target &&
136
197
  !target.generated &&
137
- target.repo === reader.repo &&
198
+ sameTree(target, reader, sources) &&
138
199
  target.version !== reader.version
139
200
  ) {
140
201
  return {
@@ -0,0 +1,31 @@
1
+ /**
2
+ * A JSON value kept in `localStorage`, read and written without ever throwing.
3
+ *
4
+ * Storage is the one browser API that fails for reasons the page cannot see: a
5
+ * private window, a blocked site, a full quota, or a value some other script
6
+ * wrote under the same key. Every reader here wants the same answer to all of
7
+ * those — "nothing stored" — and every writer the same shrug, because what is
8
+ * kept is a convenience and never worth an error the reader has to read.
9
+ *
10
+ * `unknown` on the way out, deliberately: the stored value crossed a boundary
11
+ * the type system cannot follow, so the caller checks its shape rather than
12
+ * trusting a cast.
13
+ */
14
+ export function readStoredJson(key: string): unknown {
15
+ try {
16
+ const stored = window.localStorage.getItem(key);
17
+ return stored ? (JSON.parse(stored) as unknown) : undefined;
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ }
22
+
23
+ /** Whether the value was kept. A failure is not reported further than that. */
24
+ export function writeStoredJson(key: string, value: unknown): boolean {
25
+ try {
26
+ window.localStorage.setItem(key, JSON.stringify(value));
27
+ return true;
28
+ } catch {
29
+ return false;
30
+ }
31
+ }
@@ -65,20 +65,13 @@ export function versionChoices(
65
65
  current: DuxtResolvedSource | undefined,
66
66
  configured: DuxtLink[] | undefined
67
67
  ): DuxtLink[] {
68
- // A global generated section has no version of its own, but its reader still
69
- // needs the documentation context of the source that owns it. Start from
70
- // that source's default edition: its choices lead to the version overviews,
71
- // never to non-existent versioned copies of the generated section.
72
- const global = current?.generated?.versioning === 'global';
68
+ // A GLOBAL generated section has no version of its own and serves one URL,
69
+ // so every entry would move the reader off it. A per-version one — the API
70
+ // reference, the changelog — is scoped below like any other artefact, which
71
+ // is what keeps a reader in it when they change versions.
72
+ if (current?.generated?.versioning === 'global') return [];
73
73
 
74
- if (global && current?.generated?.type !== 'changelog') return [];
75
-
76
- const context = global
77
- ? sources.find(
78
- (source) =>
79
- source.repo === current.repo && source.isDefault && !source.generated
80
- )
81
- : current;
74
+ const context = current;
82
75
 
83
76
  if (!context) return [];
84
77
 
@@ -57,6 +57,27 @@ export function versionPath(
57
57
  return `${target}${rest}` || '/';
58
58
  }
59
59
 
60
+ /**
61
+ * Where the switcher sends a reader who picks another version.
62
+ *
63
+ * The same page in that version, as `versionPath` answers — unless that page is
64
+ * one the target never published. A release page is the case this exists for:
65
+ * every version publishes its own changelog, and an older one has no page for
66
+ * a release cut after it. `missing` holds the target paths known not to exist,
67
+ * and such a pick lands at the target's own root — the changelog overview of
68
+ * that version — rather than at a 404.
69
+ */
70
+ export function versionSwitchPath(
71
+ path: string,
72
+ from: string | undefined,
73
+ to: string,
74
+ missing: ReadonlySet<string>
75
+ ): string {
76
+ const target = versionPath(path, from, to);
77
+
78
+ return missing.has(target) ? to || '/' : target;
79
+ }
80
+
60
81
  /**
61
82
  * Re-exported so the app can auto-import it.
62
83
  *
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ import { createJiti } from 'jiti';
3
+
4
+ /**
5
+ * The cache key for a site's remote sources, as a command.
6
+ *
7
+ * PLAIN JAVASCRIPT for the reason `duxt.mjs` states: Node refuses to strip
8
+ * types from a file under `node_modules`, so a TypeScript entry point dies on
9
+ * the first real install with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
10
+ *
11
+ * Everything it decides is in `sources-cache.ts`, where it is typed and tested.
12
+ * This file exists to have no logic in it.
13
+ *
14
+ * Written for a CI step that caches `.data/content` between runs:
15
+ *
16
+ * duxt-cache-key --github >> "$GITHUB_OUTPUT"
17
+ *
18
+ * and readable without one, which is what makes "why did my cache miss" a
19
+ * question with an answer.
20
+ */
21
+ // `node:sqlite` is not reached on this path, but the config is loaded through
22
+ // the same modules the report loads it through — and an experimental-feature
23
+ // warning in the middle of a `$GITHUB_OUTPUT` capture is a corrupt step output,
24
+ // not merely noise.
25
+ process.removeAllListeners('warning');
26
+
27
+ const guide = 'https://duxt.app/guides/cache-build-work';
28
+ const argv = process.argv.slice(2);
29
+
30
+ if (argv.includes('--help') || argv.includes('-h')) {
31
+ console.log(`Usage: duxt-cache-key [--root <site>] [--github]
32
+
33
+ Print the cache key and paths for remote Content sources.
34
+ Documentation: ${guide}`);
35
+ process.exit(0);
36
+ }
37
+
38
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
39
+
40
+ const { duxtSourcesCache, runDuxtSourcesCacheCli } =
41
+ await jiti.import('../sources-cache');
42
+
43
+ try {
44
+ const rootIndex = argv.indexOf('--root');
45
+ const rootDir = rootIndex === -1 ? undefined : argv[rootIndex + 1];
46
+
47
+ const { output, exitCode } = runDuxtSourcesCacheCli(
48
+ argv,
49
+ duxtSourcesCache({ rootDir })
50
+ );
51
+
52
+ console.log(output);
53
+ process.exitCode = exitCode;
54
+ } catch (error) {
55
+ // The message, not the stack: everything thrown on this path is a statement
56
+ // about the SITE's own source list, written to be read by whoever wrote it.
57
+ console.error(
58
+ `${error instanceof Error ? error.message : String(error)}\nDocumentation: ${guide}`
59
+ );
60
+ process.exitCode = 1;
61
+ }
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env node
2
+ import { createJiti } from 'jiti';
3
+
4
+ /**
5
+ * The rendered-OG-image cache, as a command.
6
+ *
7
+ * PLAIN JAVASCRIPT for the reason `duxt.mjs` states: Node refuses to strip
8
+ * types from a file under `node_modules`, so a TypeScript entry point dies on
9
+ * the first real install with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
10
+ *
11
+ * Everything it decides is in `og-image-cache.ts`, where it is typed and
12
+ * tested. This file exists to have no logic in it.
13
+ *
14
+ * Written for a deploy that caches the rendered images between runs — once
15
+ * before the build, to say what to restore and under which key:
16
+ *
17
+ * duxt-og-cache --root www --run "$GITHUB_SHA" --github >> "$GITHUB_OUTPUT"
18
+ *
19
+ * and once after it, to say what that was worth:
20
+ *
21
+ * duxt-og-cache --root www --report --since "$STARTED" --log build.log
22
+ *
23
+ * Both are readable without a workflow, which is what makes "why did my cache
24
+ * miss" and "did it help" questions with answers.
25
+ */
26
+ // An experimental-feature warning in the middle of a `$GITHUB_OUTPUT` capture
27
+ // is a corrupt step output, not merely noise.
28
+ process.removeAllListeners('warning');
29
+
30
+ const guide = 'https://duxt.app/guides/cache-build-work';
31
+ const argv = process.argv.slice(2);
32
+
33
+ if (argv.includes('--help') || argv.includes('-h')) {
34
+ console.log(`Usage: duxt-og-cache [--root <site>] [--run <id>] [--github]
35
+ duxt-og-cache --report [--root <site>] [--since <ms>] [--log <file>]
36
+
37
+ Print a rendered-OG cache key or report reuse after a build.
38
+ Documentation: ${guide}`);
39
+ process.exit(0);
40
+ }
41
+
42
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
43
+
44
+ const { duxtOgImageCacheCommand } = await jiti.import('../og-image-cache');
45
+
46
+ try {
47
+ const { output, exitCode } = duxtOgImageCacheCommand(argv);
48
+
49
+ console.log(output);
50
+ process.exitCode = exitCode;
51
+ } catch (error) {
52
+ // The message, not the stack: everything thrown on this path is a statement
53
+ // about the SITE, written to be read by whoever built it.
54
+ console.error(
55
+ `${error instanceof Error ? error.message : String(error)}\nDocumentation: ${guide}`
56
+ );
57
+ process.exitCode = 1;
58
+ }
@@ -2,7 +2,7 @@
2
2
  import { createJiti } from 'jiti';
3
3
 
4
4
  /**
5
- * The report, as a command.
5
+ * The `duxt` command.
6
6
  *
7
7
  * PLAIN JAVASCRIPT, and it has to be: Node refuses to strip types from a file
8
8
  * under `node_modules`, so a TypeScript entry point runs in this repository —
@@ -14,8 +14,9 @@ import { createJiti } from 'jiti';
14
14
  * `duxt-app-config.ts` already reads a site's config, so the rest of the layer
15
15
  * needs no build step to be reachable from here.
16
16
  *
17
- * Everything the command decides is in `report.ts`, where it is typed and
18
- * tested. This file exists to have no logic in it.
17
+ * Everything the command decides is in `cli.ts`, where it is typed and tested.
18
+ * This file exists to have no logic in it: it hands over the arguments, prints
19
+ * the two streams and sets the exit code.
19
20
  */
20
21
  // Content's cache is read through `node:sqlite`, which announces itself as
21
22
  // experimental on every run. True, and not this command's news to break to
@@ -25,15 +26,20 @@ process.removeAllListeners('warning');
25
26
 
26
27
  const jiti = createJiti(import.meta.url, { interopDefault: true });
27
28
 
28
- const { duxtReport, runDuxtReportCli } = await jiti.import('../report');
29
+ const { runDuxtCli } = await jiti.import('../cli');
30
+ const { duxtReport } = await jiti.import('../report');
29
31
 
30
32
  try {
31
- const { output, exitCode } = runDuxtReportCli(
33
+ // `duxtReport` is passed rather than called: usage has to answer on a site
34
+ // that has never been built, so nothing may read a config or a parse cache
35
+ // until the arguments have been read and found to ask for a report.
36
+ const { stdout, stderr, exitCode } = await runDuxtCli(
32
37
  process.argv.slice(2),
33
- duxtReport()
38
+ duxtReport
34
39
  );
35
40
 
36
- console.log(output);
41
+ if (stdout) console.log(stdout);
42
+ if (stderr) console.error(stderr);
37
43
  process.exitCode = exitCode;
38
44
  } catch (error) {
39
45
  // The message, not the stack. Everything thrown on this path is a statement