@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
@@ -18,15 +18,29 @@
18
18
  * the one page it was written as, for a project that just wants it shown. The
19
19
  * cost of two rendering paths was weighed and accepted, and the difference runs
20
20
  * further than the page count: a flat changelog is an ORDINARY docs page and
21
- * keeps the docs chrome, which is why `layout` is answered per declaration.
21
+ * keeps the docs shell, which is why `layout` is answered per declaration.
22
22
  *
23
- * Pure text in, files out. What the split history LOOKS like — the timeline,
23
+ * Text in, files out — plus ONE FACT THE FILE DOES NOT HOLD. A release is a
24
+ * range of commits, and who wrote them is in the repository rather than in the
25
+ * changelog; the authors of the changelog FILE are whoever ran the release
26
+ * tool, which is not the same set of people and is the wrong answer given
27
+ * confidently. So where an input names a checkout the build may read
28
+ * (`DuxtSectionInput.root`) the people behind each tag are read out of git;
29
+ * where it does not — a remote clone, a test, a fixture — nothing is read and
30
+ * no release names anybody. What the split history LOOKS like — the timeline,
24
31
  * the group badges, the filters — belongs to `layouts/changelog.vue` and the
25
32
  * two components this file writes calls to.
26
33
  */
27
34
  import { stringify as stringifyYaml } from 'yaml';
35
+ import { frontmatterBlock } from './frontmatter';
36
+ import type { DuxtContributor } from './git-contributors';
37
+ import {
38
+ contributorsForVersion,
39
+ releaseContributors
40
+ } from './git-contributors';
28
41
  import type {
29
42
  DuxtSectionContext,
43
+ DuxtSectionInput,
30
44
  DuxtSectionOptions,
31
45
  DuxtSectionPage,
32
46
  DuxtSectionType
@@ -39,7 +53,7 @@ import { slugify } from './sources-resolve';
39
53
  * A layout and two MDC components, because that is the whole of what a
40
54
  * consumer can override: dropping a `ChangelogGroup.vue` of their own into
41
55
  * `app/components/content/` replaces the layer's, exactly as it does for a
42
- * callout, and a `layouts/changelog.vue` replaces the chrome around it.
56
+ * callout, and a `layouts/changelog.vue` replaces the shell around it.
43
57
  */
44
58
  export const DUXT_CHANGELOG_LAYOUT = 'changelog';
45
59
  export const DUXT_CHANGELOG_RELEASES = 'changelog-releases';
@@ -119,14 +133,22 @@ interface Group {
119
133
  export const changelogSectionType: DuxtSectionType = {
120
134
  parse: parseChangelog,
121
135
  /**
122
- * ONE GLOBAL HISTORY, read from the default version.
136
+ * PER VERSION, like the documentation and the API reference beside it.
123
137
  *
124
- * A changelog is not a per-version document that happens to mention other
125
- * versions — it is the list OF the versions, and building one copy per
126
- * version would publish the same file under three URLs, each of them missing
127
- * the releases that came after it.
138
+ * Every documentation version publishes the CHANGELOG.md it shipped with, at
139
+ * a route inside that version — `/v2.x/releases` beside `/v2.x/guides`. It
140
+ * used to be one global history at a version-neutral URL, and that is what
141
+ * made the switcher special: it could not keep a reader in the changelog
142
+ * when they chose another version. Each copy holds everything its own file
143
+ * holds, earlier releases included, which is exactly what that version knew.
128
144
  */
129
- versioning: 'global',
145
+ versioning: 'per-version',
146
+ /**
147
+ * REQUIRED in every published version. A version with no CHANGELOG.md is a
148
+ * build error: leaving the version out, or falling back to another version's
149
+ * history, would both publish something the version never shipped.
150
+ */
151
+ artefact: 'required',
130
152
  /**
131
153
  * The original, in every language.
132
154
  *
@@ -141,7 +163,7 @@ export const changelogSectionType: DuxtSectionType = {
141
163
  *
142
164
  * The split history is a timeline: the releases in the sidebar, a page per
143
165
  * release, no table of contents over four bullet points. The flat file is a
144
- * long ordinary page, and the docs chrome is exactly what it wants — a
166
+ * long ordinary page, and the docs shell is exactly what it wants — a
145
167
  * contents column listing the releases most of all. Same type, two products,
146
168
  * so the question is answered from the declaration's own options.
147
169
  */
@@ -188,10 +210,10 @@ function granularityOf(options: DuxtSectionOptions): Granularity {
188
210
  }
189
211
 
190
212
  function parseChangelog(
191
- artefact: string,
213
+ input: DuxtSectionInput,
192
214
  context: DuxtSectionContext
193
215
  ): DuxtSectionPage[] {
194
- const lines = artefact.split(/\r?\n/);
216
+ const lines = input.text().split(/\r?\n/);
195
217
  const headings = headingsOf(lines);
196
218
  const starts = headings.filter((heading) => release(heading.text));
197
219
 
@@ -222,7 +244,15 @@ function parseChangelog(
222
244
  const width = String(releases.length).length;
223
245
 
224
246
  return [
225
- index(preamble, releases, context),
247
+ // Read once for the whole file, not once per release: the answer is a map
248
+ // over every tag the checkout has, and asking git per release would be one
249
+ // process per release for the same bytes.
250
+ index(
251
+ preamble,
252
+ releases,
253
+ context,
254
+ input.root ? releaseContributors(input.root) : undefined
255
+ ),
226
256
  ...releases.map((entry, position) =>
227
257
  page(entry, String(position + 1).padStart(width, '0'))
228
258
  )
@@ -246,7 +276,7 @@ function flat(
246
276
  return {
247
277
  file: 'index.md',
248
278
  body: [
249
- frontmatter({ title: context.label, release: newest }),
279
+ frontmatterBlock({ title: context.label, release: newest }),
250
280
  '',
251
281
  ...trim(withoutTitle(lines)),
252
282
  ''
@@ -424,20 +454,40 @@ function entries(lines: string[]): number {
424
454
  function index(
425
455
  preamble: string[],
426
456
  releases: Release[],
427
- context: DuxtSectionContext
457
+ context: DuxtSectionContext,
458
+ contributors?: Map<string, DuxtContributor[]>
428
459
  ): DuxtSectionPage {
429
460
  const body = trim(withoutTitle(preamble));
430
461
 
431
462
  const props = {
432
- releases: releases.map((entry) => ({
433
- version: entry.version,
434
- date: entry.date,
435
- to: `${context.prefix}/${segment(entry.version)}`,
436
- groups: groupsOf(trim(entry.body)).groups.map((group) => ({
437
- name: group.name,
438
- count: group.count
439
- }))
440
- }))
463
+ releases: releases.map((entry) => {
464
+ const people = contributorsForVersion(contributors, entry.version);
465
+
466
+ return {
467
+ version: entry.version,
468
+ date: entry.date,
469
+ to: `${context.prefix}/${segment(entry.version)}`,
470
+ groups: groupsOf(trim(entry.body)).groups.map((group) => ({
471
+ name: group.name,
472
+ count: group.count
473
+ })),
474
+ // A NAME AND, WHERE GIT CARRIES ONE, A HANDLE. Not the address the
475
+ // identity was computed from: these props are written into a page that
476
+ // is prerendered, crawled, indexed, put into `llms-full.txt` and handed
477
+ // to a model on request, and an email that reaches all of that is a
478
+ // different object from the same email inside a commit. Not the commit
479
+ // COUNT either — the order already carries it, and a per-release tally
480
+ // is a number nobody asked this page for.
481
+ ...(people?.length
482
+ ? {
483
+ contributors: people.map((person) => ({
484
+ name: person.name,
485
+ ...(person.username ? { username: person.username } : {})
486
+ }))
487
+ }
488
+ : {})
489
+ };
490
+ })
441
491
  };
442
492
 
443
493
  return {
@@ -447,7 +497,7 @@ function index(
447
497
  // props below. Keep the newest release beside the page title instead:
448
498
  // Content exposes frontmatter on the page query that `DuxtVersion` uses,
449
499
  // and parsing the file again there would make the two answers drift.
450
- frontmatter({ title: context.label, release: releases[0]?.version }),
500
+ frontmatterBlock({ title: context.label, release: releases[0]?.version }),
451
501
  '',
452
502
  // NO `<h1>` OF ITS OWN. The page draws the docs header — breadcrumb,
453
503
  // title, description, the copy control beside it — for exactly the
@@ -479,7 +529,7 @@ function page(entry: Release, order: string): DuxtSectionPage {
479
529
  return {
480
530
  file: `${order}.${segment(entry.version)}.md`,
481
531
  body: [
482
- frontmatter({
532
+ frontmatterBlock({
483
533
  title: entry.version,
484
534
  date: entry.date,
485
535
  compare: entry.compare
@@ -635,20 +685,3 @@ function component(
635
685
  fence
636
686
  ].join('\n');
637
687
  }
638
-
639
- /**
640
- * A frontmatter block YAML can read back.
641
- *
642
- * Every value is written as a JSON string, which is also a YAML double-quoted
643
- * scalar — so a release title carrying a colon cannot end the mapping early,
644
- * which is the exact failure `tests/frontmatter-yaml.test.ts` exists over.
645
- */
646
- function frontmatter(fields: Record<string, string | undefined>): string {
647
- return [
648
- '---',
649
- ...Object.entries(fields)
650
- .filter(([, value]) => value !== undefined)
651
- .map(([key, value]) => `${key}: ${JSON.stringify(value)}`),
652
- '---'
653
- ].join('\n');
654
- }
@@ -4,7 +4,7 @@
4
4
  * The second type in the registry, and the first one that answers the questions
5
5
  * the registry was built to ask. A changelog is one global history in the
6
6
  * language it was written in; a reference is **per version** and **per locale**,
7
- * and it needs a **layout** the docs chrome does not give it. Those three
7
+ * and it needs a **layout** the docs shell does not give it. Those three
8
8
  * differences are policies on the type rather than branches in the scaffold,
9
9
  * which is exactly what #9 claimed they would be — so this file adds a parser
10
10
  * and three declarations, and nothing else.
@@ -24,6 +24,8 @@
24
24
  * responses, the try-it client — travels as props.
25
25
  */
26
26
  import { stringify as stringifyYaml } from 'yaml';
27
+ import { frontmatterBlock } from './frontmatter';
28
+ import { firstLine, pageOrder, urlSegment } from './section-markdown';
27
29
  import type {
28
30
  DuxtOpenApiOperation,
29
31
  DuxtOpenApiSecurity,
@@ -34,6 +36,7 @@ import type {
34
36
  import { compactOpenApi, parseOpenApiDocument } from './openapi-parse';
35
37
  import type {
36
38
  DuxtSectionContext,
39
+ DuxtSectionInput,
37
40
  DuxtSectionPage,
38
41
  DuxtSectionType
39
42
  } from './sections-resolve';
@@ -75,13 +78,13 @@ export const openapiSectionType: DuxtSectionType = {
75
78
  };
76
79
 
77
80
  function parseOpenApiSection(
78
- artefact: string,
81
+ input: DuxtSectionInput,
79
82
  context: DuxtSectionContext
80
83
  ): DuxtSectionPage[] {
81
84
  let spec: DuxtOpenApiSpec;
82
85
 
83
86
  try {
84
- spec = parseOpenApiDocument(artefact);
87
+ spec = parseOpenApiDocument(input.text());
85
88
  } catch (error) {
86
89
  // Rethrown rather than swallowed into an empty section: the scaffold turns
87
90
  // "no pages" into the right severity for local and remote alike, but a
@@ -103,8 +106,8 @@ function parseOpenApiSection(
103
106
 
104
107
  const groups = spec.tags.map((tag, index) => ({
105
108
  tag,
106
- slug: segment(tag.name),
107
- order: order(index, spec.tags!.length)
109
+ slug: urlSegment(tag.name),
110
+ order: pageOrder(index, spec.tags!.length)
108
111
  }));
109
112
 
110
113
  // A tag whose name slugifies to the same segment as another's would serve
@@ -228,7 +231,7 @@ function pagesFor(
228
231
  group,
229
232
  operation,
230
233
  slugs[position]!,
231
- order(position, width)
234
+ pageOrder(position, width)
232
235
  )
233
236
  )
234
237
  ];
@@ -322,8 +325,8 @@ function operationSlugs(operations: DuxtOpenApiOperation[]): string[] {
322
325
 
323
326
  return operations.map((operation) => {
324
327
  const base =
325
- segment(operation.operationId ?? '') ||
326
- segment(`${operation.method}-${operation.path}`) ||
328
+ urlSegment(operation.operationId ?? '') ||
329
+ urlSegment(`${operation.method}-${operation.path}`) ||
327
330
  operation.method;
328
331
 
329
332
  let candidate = base;
@@ -335,62 +338,6 @@ function operationSlugs(operations: DuxtOpenApiOperation[]): string[] {
335
338
  });
336
339
  }
337
340
 
338
- /**
339
- * A URL segment: lowercase, no dots.
340
- *
341
- * The dots matter. Content reads a name made of digits and dots as a version
342
- * and stops refining it, which would leave the `NN.` ordering prefix in the
343
- * URL — the same trap `sections-changelog.ts` answers with a leading `v`. An
344
- * endpoint at `/v1.0/pets` walks straight into it, so the dot goes.
345
- */
346
- function segment(value: string): string {
347
- return value
348
- .toLowerCase()
349
- .replace(/[^a-z0-9]+/g, '-')
350
- .replace(/^-+|-+$/g, '');
351
- }
352
-
353
- /** Zero-padded so ten pages still sort the way they read. */
354
- function order(index: number, total: number): string {
355
- return String(index + 1).padStart(String(total).length, '0');
356
- }
357
-
358
- /**
359
- * The first paragraph of a description, as one line of PLAIN text.
360
- *
361
- * Plain matters because of where this goes: a page's `description`
362
- * frontmatter, which becomes the meta description, the og tag and the card in
363
- * a search result, and the summary on a group card. A CommonMark description
364
- * carrying `*emphasis*` or a `[link](url)` renders as prose everywhere it is
365
- * rendered as prose — and as its own punctuation everywhere it is not.
366
- *
367
- * Deliberately small. It undoes the four inline constructs a one-line summary
368
- * actually meets — emphasis, code spans, links and images — and leaves
369
- * everything else alone rather than growing into a second Markdown parser
370
- * whose disagreements with the first would be invisible.
371
- */
372
- function firstLine(value?: string): string | undefined {
373
- const line = plain(value?.split(/\n\s*\n/)[0] ?? '')
374
- .replace(/\s+/g, ' ')
375
- .trim();
376
-
377
- return line || undefined;
378
- }
379
-
380
- /** Inline Markdown, as the text it renders to. */
381
- function plain(value: string): string {
382
- return (
383
- value
384
- // An image before a link: `![alt](src)` is a link with a `!` in front, and
385
- // taking the link first would leave the `!` behind.
386
- .replaceAll(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
387
- .replaceAll(/\[([^\]]*)\]\([^)]*\)/g, '$1')
388
- .replaceAll(/`([^`]*)`/g, '$1')
389
- .replaceAll(/(\*\*|__)(.+?)\1/g, '$2')
390
- .replaceAll(/(\*|_)(.+?)\1/g, '$2')
391
- );
392
- }
393
-
394
341
  /**
395
342
  * An MDC block component with YAML props and Markdown inside it.
396
343
  *
@@ -428,23 +375,5 @@ function page(
428
375
  fields: Record<string, string | undefined>,
429
376
  lines: string[]
430
377
  ): string {
431
- return [frontmatter(fields), '', ...lines, ''].join('\n');
432
- }
433
-
434
- /**
435
- * A frontmatter block YAML can read back.
436
- *
437
- * Every value is a JSON string, which is also a YAML double-quoted scalar — so
438
- * a summary carrying a colon cannot end the mapping early. The rule
439
- * `tests/frontmatter-yaml.test.ts` exists over, and a title like
440
- * `GET /pets/{petId}: not found` is exactly the shape that breaks it.
441
- */
442
- function frontmatter(fields: Record<string, string | undefined>): string {
443
- return [
444
- '---',
445
- ...Object.entries(fields)
446
- .filter(([, value]) => value !== undefined && value !== '')
447
- .map(([key, value]) => `${key}: ${JSON.stringify(value)}`),
448
- '---'
449
- ].join('\n');
378
+ return [frontmatterBlock(fields), '', ...lines, ''].join('\n');
450
379
  }
@@ -28,6 +28,7 @@ import {
28
28
  resolveSources,
29
29
  slugify
30
30
  } from './sources-resolve';
31
+ import { brunoSectionType } from './sections-bruno';
31
32
  import { changelogSectionType } from './sections-changelog';
32
33
  import { openapiSectionType } from './sections-openapi';
33
34
 
@@ -167,10 +168,129 @@ export interface DuxtSectionPage {
167
168
  body: string;
168
169
  }
169
170
 
171
+ /**
172
+ * The artefact a type reads, as the type reads it.
173
+ *
174
+ * A LAZY HANDLE rather than a string, and that is the whole of this seam's
175
+ * second version. `parse` took `artefact: string`, which said one thing the
176
+ * registry had never meant to promise: that an artefact is one file. A
177
+ * changelog is, an OpenAPI document is — a Bruno collection is `bruno.json`
178
+ * beside a directory of `.bru` files, `folder.bru` ordering and an
179
+ * `environments/` folder nobody should publish, and no single string carries
180
+ * it.
181
+ *
182
+ * So the type is handed WHERE its input is and is left to read it. The central
183
+ * resolver keeps the severity policy, the report and the prefix; what a tree
184
+ * means is the type's, which is the only place the knowledge belongs — a
185
+ * resolver that walked a Bruno collection would have to walk the next type's
186
+ * too.
187
+ *
188
+ * `text` throws rather than returning `''` for a directory: a type that asks a
189
+ * directory for its contents has a bug, and an empty string turns it into an
190
+ * empty section reported as a missing artefact three files away.
191
+ */
192
+ export interface DuxtSectionInput {
193
+ /** The declared path, relative to the source's own root. */
194
+ path: string;
195
+ /**
196
+ * The checkout `path` resolves against, where the artefact was read out of a
197
+ * repository whose history this build is allowed to read.
198
+ *
199
+ * ABSENT IS THE DEFAULT, AND MEANS "DO NOT LOOK". It is filled in only for an
200
+ * artefact read out of the repository being built. A remote source is a
201
+ * `--depth 1` clone, where every commit looks as though one person wrote it
202
+ * — wrong data rather than missing data, which is the line
203
+ * `modules/git-meta.ts` already draws and for the same reason. The one
204
+ * exception is a remote source that IS the repository being built: the
205
+ * artefact still comes from the download, but this points at the local
206
+ * checkout, which carries the same tags with their whole history — see
207
+ * `localHistoryFor`. An in-memory
208
+ * input leaves it unset too, so a type under test never reaches a real
209
+ * repository and a fixture version that happens to match a real tag cannot
210
+ * quietly pick up real people.
211
+ *
212
+ * A type that only turns a file into pages never asks. The release history
213
+ * does: which people a release carries is a fact about the commits between
214
+ * two tags, and no changelog file holds it.
215
+ */
216
+ root?: string;
217
+ /** Whether the declared path is one file or a tree. */
218
+ kind: 'file' | 'directory';
219
+ /** The whole file. Throws when the input is a directory. */
220
+ text: () => string;
221
+ /**
222
+ * Every file under a directory input, as `/`-separated relative paths.
223
+ *
224
+ * Sorted, so a type that walks them produces the same pages on every machine
225
+ * — a directory listing is not ordered, and a section whose page order
226
+ * depended on the filesystem would reorder itself on a different one.
227
+ *
228
+ * Empty for a file input.
229
+ */
230
+ files: () => string[];
231
+ /** One file under a directory input, by its relative path. */
232
+ read: (file: string) => string;
233
+ }
234
+
235
+ /**
236
+ * An input over files already in memory.
237
+ *
238
+ * The pure half of what `sections.ts` builds out of `node:fs`, so a type's
239
+ * parser is testable without a fixture directory — and so a consumer's own type
240
+ * can be given one.
241
+ */
242
+ export function duxtSectionInput(
243
+ path: string,
244
+ files: Record<string, string> | string
245
+ ): DuxtSectionInput {
246
+ if (typeof files === 'string') {
247
+ return {
248
+ path,
249
+ kind: 'file',
250
+ text: () => files,
251
+ files: () => [],
252
+ read: () => ''
253
+ };
254
+ }
255
+
256
+ const names = Object.keys(files).sort();
257
+
258
+ return {
259
+ path,
260
+ kind: 'directory',
261
+ text: () => {
262
+ throw new Error(
263
+ `duxt: "${path}" is a directory, and this type asked it for a file.`
264
+ );
265
+ },
266
+ files: () => [...names],
267
+ read: (file) => files[file] ?? ''
268
+ };
269
+ }
270
+
170
271
  /** What the parser is told about the section it is filling. */
171
272
  export interface DuxtSectionContext {
172
273
  /** The declared label, which is also the index page's title. */
173
274
  label: string;
275
+ /**
276
+ * The Content collection these pages land in.
277
+ *
278
+ * The one identity that is unique per version AND per locale — a prefix is
279
+ * not, since two languages deliberately claim the same one. A type that has
280
+ * to name a build-time artefact of its own (the Bruno collection's ZIP) needs
281
+ * a name the module emitting it can compute the same way, and this is it.
282
+ */
283
+ collection: string;
284
+ /**
285
+ * Whether the artefact came out of a repository Content downloads.
286
+ *
287
+ * A type that only turns a file into pages never asks. One whose section
288
+ * offers something built BESIDE the pages does: `section-reports.ts` states
289
+ * the limit this reflects — a remote checkout lands wherever Content's
290
+ * hash-cache put it, which is a directory known only inside the collection
291
+ * that declared it, so nothing outside can read the artefact a second time.
292
+ */
293
+ remote: boolean;
174
294
  /** The URL prefix its pages are served at, e.g. `/releases`. */
175
295
  prefix: string;
176
296
  /** The declaration's own `options`, empty where it named none. */
@@ -223,15 +343,42 @@ export interface DuxtSectionReport {
223
343
  */
224
344
  export interface DuxtSectionType {
225
345
  /** The artefact, split into pages carrying their own frontmatter. */
226
- parse: (artefact: string, context: DuxtSectionContext) => DuxtSectionPage[];
346
+ parse: (
347
+ input: DuxtSectionInput,
348
+ context: DuxtSectionContext
349
+ ) => DuxtSectionPage[];
350
+ /**
351
+ * Whether the declared path names a file or a directory.
352
+ *
353
+ * `file` is the default and what the first two types are: the path is opened
354
+ * and its contents handed over. `directory` hands the type the tree instead —
355
+ * `input.files()` and `input.read()` — and is what a client-side collection
356
+ * like Bruno's needs, since there is no one file to open.
357
+ *
358
+ * A POLICY rather than a guess at the path: a type knows what it reads, and a
359
+ * resolver that stat'ed the path would report a missing directory as a
360
+ * missing file and say the wrong thing about both.
361
+ */
362
+ input?: 'file' | 'directory';
227
363
  /**
228
364
  * How this type behaves against the one-collection-per-version mechanic.
229
365
  *
230
366
  * `global` is one history read from the default version and served at a
231
- * version-neutral URL, with the switcher suppressed — what a changelog is.
232
- * `per-version` is a section per version, like any other page.
367
+ * version-neutral URL, with the switcher suppressed. `per-version` is a
368
+ * section per version, like any other page — what the API reference and the
369
+ * changelog are.
233
370
  */
234
371
  versioning: 'global' | 'per-version';
372
+ /**
373
+ * Whether every published version has to have the artefact.
374
+ *
375
+ * `optional`, the default, is the rule `missingSectionArtefact` states for a
376
+ * source: a local path that does not exist fails the build, a remote one is
377
+ * recorded and its section simply not built. `required` fails the build for
378
+ * a remote version too — for a type whose missing file cannot be quietly
379
+ * skipped without publishing the wrong thing, as a changelog cannot.
380
+ */
381
+ artefact?: 'required' | 'optional';
235
382
  /**
236
383
  * What a localised site shows when the artefact has one language.
237
384
  *
@@ -251,11 +398,11 @@ export interface DuxtSectionType {
251
398
  * The layout its pages render in, through the shared slot.
252
399
  *
253
400
  * A layout name a type binds is PUBLIC SURFACE — renaming one later is a
254
- * `feat!:`. Unset renders the section in the ordinary docs chrome.
401
+ * `feat!:`. Unset renders the section in the ordinary docs shell.
255
402
  *
256
403
  * A FUNCTION where the declaration's own options decide: the same type can
257
404
  * produce two different things, and a changelog is the case that proves it —
258
- * split into a page per release it is a timeline with chrome of its own,
405
+ * split into a page per release it is a timeline with a layout of its own,
259
406
  * rendered as the one file it was written as it is an ordinary docs page and
260
407
  * wants the sidebar, the breadcrumb and the table of contents back.
261
408
  */
@@ -269,6 +416,7 @@ export type DuxtSectionTypes = Record<string, DuxtSectionType>;
269
416
 
270
417
  /** The types the layer ships. */
271
418
  export const duxtBuiltinSectionTypes: DuxtSectionTypes = {
419
+ bruno: brunoSectionType,
272
420
  changelog: changelogSectionType,
273
421
  openapi: openapiSectionType
274
422
  };
@@ -485,6 +633,10 @@ export function resolveGeneratedSections(
485
633
  }`,
486
634
  prefix,
487
635
  repo: base.entry.repo,
636
+ // The SOURCE's name, not the artefact's. A generated section is
637
+ // published by the project its source is, and the artefact has a
638
+ // label of its own that the search caption draws beside this.
639
+ name: base.entry.name,
488
640
  // `global` is version-NEUTRAL, so it carries no version at all: the
489
641
  // switcher lists what has a version, and a changelog must not be
490
642
  // offered as one of the versions of the documentation beside it.
@@ -639,12 +791,14 @@ export function generatedSectionRef(
639
791
  export function sectionPages(
640
792
  entry: DuxtResolvedSource,
641
793
  type: DuxtSectionType,
642
- artefact: string
794
+ input: DuxtSectionInput
643
795
  ): DuxtSectionPage[] {
644
796
  const warnings: string[] = [];
645
797
 
646
- const pages = type.parse(artefact, {
798
+ const pages = type.parse(input, {
647
799
  label: entry.generated!.label,
800
+ collection: entry.collection,
801
+ remote: entry.generated!.remote,
648
802
  prefix: entry.prefix,
649
803
  options: entry.generated!.options ?? {},
650
804
  warn: (message) => {
@@ -687,10 +841,22 @@ export function sectionPages(
687
841
  */
688
842
  export function missingSectionArtefact(
689
843
  entry: DuxtResolvedSource,
690
- file: string
844
+ file: string,
845
+ type?: Pick<DuxtSectionType, 'artefact'>
691
846
  ): DuxtSectionPage[] {
692
847
  entry.generated!.report = { pages: 0, warnings: [], missing: true };
693
848
 
849
+ // A type that needs its artefact in EVERY published version fails the build
850
+ // for a remote version too — see `DuxtSectionType.artefact`.
851
+ if (entry.generated!.remote && type?.artefact === 'required') {
852
+ throw new Error(
853
+ `duxt: the generated section "${entry.generated!.label}" needs ` +
854
+ `${entry.path} in every published version, and ` +
855
+ `${entry.version ?? entry.ref ?? 'this version'} has none ` +
856
+ `(looked in ${file}).`
857
+ );
858
+ }
859
+
694
860
  // Remote: recorded, not printed. `validate-report.ts` reads the report and
695
861
  // says it once, in the same list as every other finding — see `sectionPages`.
696
862
  if (entry.generated!.remote) return [];