@kirchdev/duxt 0.3.4 → 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,380 @@
1
+ /**
2
+ * The one extension point duxt opens: what the site holds, as search records.
3
+ *
4
+ * THE DECISION BEHIND THIS FILE, because the file is the smaller half of it.
5
+ * The layer is extended by five things already — a CSS token, a shadowed
6
+ * component, a slot, `app.config`'s `duxt` key and the `sectionTypes` registry
7
+ * — and none of them reaches the build. The question was whether to open a
8
+ * generic hook registry, a `duxt.plugins` API, or neither.
9
+ *
10
+ * NEITHER, and then one hook. A distributable duxt extension is a **Nuxt
11
+ * layer**: `extends: ['@kirchdev/duxt', 'duxt-typesense']` already carries
12
+ * components, `app.config` defaults, `content.config.ts` collections, server
13
+ * routes, MCP tools and a module of its own, in one package with a version.
14
+ * A plugin system would reimplement layer resolution inside a layer, and a
15
+ * registry a plugin writes into at import time walks straight into the
16
+ * two-loader wall that shaped `sectionTypes` — `content.config.ts` and the duxt
17
+ * module are loaded by different loaders, so a mutable registry each wrote into
18
+ * would be two registries (`sections-resolve.ts`). A Nuxt BUILD hook does not:
19
+ * only the module half sees it, and that half has one loader.
20
+ *
21
+ * So there is exactly one hook, it is build-time, and it exists because three
22
+ * things want the same data and none of them can get it today: an external
23
+ * search index — Pagefind, Typesense, Meilisearch — needs every page of every
24
+ * source, version and language as flat text, and the only way to that today is
25
+ * a fork. Every other seam named as a candidate (the navigation tree, the
26
+ * redirect map, `llms.txt`, the MCP tool list, the build checks) gets no hook
27
+ * until a consumer exists that cannot be written without one. A hook is public
28
+ * surface: renaming it is a breaking change, and an unused one can never be
29
+ * removed.
30
+ *
31
+ * WHAT THE PAYLOAD DELIBERATELY IS NOT. No excerpt — a snippet is a decision
32
+ * about a result list, and the provider owns the result list. No collection
33
+ * name — that is duxt's own index bookkeeping, and a document keyed on it would
34
+ * break the day a source gains a language. No display label — `label`, `name`
35
+ * and the version caption are prose for a reader, translated per locale, and a
36
+ * record is not read by a reader. What is left is the address and the words,
37
+ * which is what every index actually stores.
38
+ *
39
+ * Split from `modules/search-records.ts` for the reason `validate-report.ts` is
40
+ * split from `modules/validate.ts`: the module half imports `@nuxt/schema` and
41
+ * Content's sqlite cache, and this half is data in, records out — so a test can
42
+ * run it without a build.
43
+ */
44
+ import { createHash } from 'node:crypto';
45
+ import type { CachedPage } from './content-cache';
46
+ import type { DuxtResolvedSource } from './sources-resolve';
47
+ import { duxtPageSearchable } from './app/utils/page-controls';
48
+
49
+ /**
50
+ * One entry of an external search index, as duxt hands it over.
51
+ *
52
+ * PROVIDER-INDEPENDENT by construction: every field is a string duxt can state
53
+ * about the site itself, and nothing here is shaped by what a particular
54
+ * service wants stored. A provider layer maps these onto its own document —
55
+ * Pagefind's custom records, a Typesense collection, a Meilisearch index — and
56
+ * duxt never learns which.
57
+ */
58
+ export interface DuxtSearchRecord {
59
+ /**
60
+ * The record's identity, and the primary key a provider stores it under.
61
+ *
62
+ * A HASH rather than the URL, and that is the one field here whose shape was
63
+ * forced from outside. Meilisearch accepts a document id of `A-Za-z0-9_-`
64
+ * only, so a URL — which carries `/`, `#` and, on a site whose headings are
65
+ * not Latin, whatever the heading was written in — cannot be one. A hex
66
+ * digest is valid everywhere, the same on every machine, and the same on
67
+ * every build the record survives unchanged, which is what lets a provider
68
+ * upsert rather than rebuild.
69
+ *
70
+ * Derived from `url`, which already carries the locale, the source, the
71
+ * version and the anchor — so two records collide exactly when they address
72
+ * the same place, which the source resolver has already made impossible.
73
+ */
74
+ id: string;
75
+ /** Where a reader lands: the public path, locale segment and anchor included. */
76
+ url: string;
77
+ /** The heading this record covers, or the page's own title for the first. */
78
+ title: string;
79
+ /** The words under that heading, markup removed and whitespace collapsed. */
80
+ content: string;
81
+ /**
82
+ * Which documentation source this came out of, as its URL segments minus the
83
+ * version — `/` for the documentation served at the root, `/harbour` for a
84
+ * second repository, `/harbour/api` for a generated section inside it.
85
+ *
86
+ * An IDENTITY, not an address, which is why the version is not in it: a facet
87
+ * built on this survives a release, and `version` beside it is what narrows
88
+ * to one.
89
+ */
90
+ source: string;
91
+ /** The version label, where the source has versions at all. */
92
+ version?: string;
93
+ /**
94
+ * The language the CONTENT is in — not the interface language a reader
95
+ * happens to be browsing in.
96
+ *
97
+ * A site whose sources are untranslated has one language of content and gets
98
+ * one set of records, even when it serves seven interface locales: the same
99
+ * paragraph indexed seven times is one answer repeated, not seven answers.
100
+ */
101
+ locale: string;
102
+ }
103
+
104
+ /** What the site's routing does to a path, and which languages it serves. */
105
+ export interface DuxtSearchRecordOptions {
106
+ /**
107
+ * The locale codes the site actually ROUTES, after `duxt.locales` has
108
+ * narrowed them — in the order the site declares them.
109
+ *
110
+ * NOT the same code space as a source's own locale, which is the trap this
111
+ * whole file had to be corrected for: `@nuxtjs/i18n` routes `de-DE` while a
112
+ * source is commonly written `locales: ['de']`, because one `de/` folder
113
+ * serves every German region. Matching the two by equality indexed nothing
114
+ * at all on a site with four translations, and said so nowhere. See
115
+ * `routedLocale`.
116
+ */
117
+ locales: readonly string[];
118
+ /** The language a source with no locale of its own is written in. */
119
+ defaultLocale: string;
120
+ /** `@nuxtjs/i18n`'s own strategy, which decides who gets a locale segment. */
121
+ strategy?:
122
+ | 'no_prefix'
123
+ | 'prefix_except_default'
124
+ | 'prefix'
125
+ | 'prefix_and_default';
126
+ }
127
+
128
+ /** One heading's worth of a page, before it is given its coordinates. */
129
+ interface Section {
130
+ /** The heading's anchor; absent for the page's own opening section. */
131
+ anchor?: string;
132
+ title: string;
133
+ content: string;
134
+ }
135
+
136
+ const HEADING = /^h[1-6]$/;
137
+
138
+ /**
139
+ * The words a minimal node shows, with its markup dropped.
140
+ *
141
+ * Content stores a parsed body as `[tag, props, ...children]` with text as
142
+ * plain strings — the shape `generated-toc.ts` and `openapi.ts` already walk.
143
+ *
144
+ * CHILDREN ARE JOINED WITH NOTHING, exactly as Content's own search sections
145
+ * do it, so `a **bold** word` reads as one sentence rather than as three. The
146
+ * separator between BLOCKS is a space, added by the caller, which is the level
147
+ * at which two paragraphs are two things.
148
+ */
149
+ function text(node: unknown): string {
150
+ if (typeof node === 'string') return node;
151
+ if (typeof node === 'number') return String(node);
152
+ if (!Array.isArray(node)) return '';
153
+
154
+ return node.slice(2).map(text).join('');
155
+ }
156
+
157
+ /** Runs of whitespace, including the newlines a Markdown body is full of. */
158
+ const normalise = (value: string) => value.replace(/\s+/g, ' ').trim();
159
+
160
+ /**
161
+ * A page, split at its headings.
162
+ *
163
+ * Mirrors `generateSearchSections` in `@nuxt/content` deliberately: a site with
164
+ * duxt's own reader-facing search AND an external index must not have the two
165
+ * disagree about what a page says. The page's own record leads and carries its
166
+ * description, exactly as Content's does.
167
+ *
168
+ * ONE DIVERGENCE, and it is the only one. duxt's reader-facing index is built
169
+ * with `ignoredTags: ['table']`, because a table flattened into one string runs
170
+ * its cells together and reads as gibberish in an EXCERPT. A record carries no
171
+ * excerpt — the provider writes its own — so dropping the table would throw
172
+ * away words a reader may well be searching for, and buy nothing.
173
+ *
174
+ * A HEADING WITH NO ANCHOR IS NOT A SECTION. MDC gives every heading an `id`,
175
+ * so this is the pathological case rather than the normal one; addressing such
176
+ * a section would mean a second record at the page's own URL, and therefore at
177
+ * the page's own id. Its words fold into the section above it instead, where
178
+ * they are still findable.
179
+ */
180
+ export function duxtSearchSections(page: Record<string, unknown>): Section[] {
181
+ const title = typeof page.title === 'string' ? page.title : '';
182
+ const description =
183
+ typeof page.description === 'string' ? page.description : '';
184
+
185
+ const sections: Section[] = [{ title, content: normalise(description) }];
186
+
187
+ const body = (page.body as { value?: unknown } | undefined)?.value;
188
+ if (!Array.isArray(body)) return sections;
189
+
190
+ for (const node of body) {
191
+ if (!Array.isArray(node)) continue;
192
+
193
+ const [tag, props] = node as [unknown, unknown];
194
+ const anchor =
195
+ props && typeof props === 'object' && !Array.isArray(props)
196
+ ? (props as { id?: unknown }).id
197
+ : undefined;
198
+
199
+ if (
200
+ typeof tag === 'string' &&
201
+ HEADING.test(tag) &&
202
+ typeof anchor === 'string' &&
203
+ anchor
204
+ ) {
205
+ sections.push({ anchor, title: normalise(text(node)), content: '' });
206
+ continue;
207
+ }
208
+
209
+ const current = sections.at(-1)!;
210
+ const words = normalise(text(node));
211
+ if (!words) continue;
212
+ current.content = current.content ? `${current.content} ${words}` : words;
213
+ }
214
+
215
+ return sections;
216
+ }
217
+
218
+ /** The language half of a locale code: `de` for `de`, and `de` for `de-DE`. */
219
+ const language = (code: string) => code.split('-')[0]!;
220
+
221
+ /**
222
+ * Which routed locale serves a source written in this language, if any.
223
+ *
224
+ * THE TWO CODE SPACES. A source says which language a folder holds — `de`,
225
+ * because one `de/` tree serves every German region, which is exactly the rule
226
+ * `localeChain` in `sources-resolve.ts` reads it by. `@nuxtjs/i18n` says which
227
+ * URL a reader is on — `de-DE`. Comparing them for equality is the bug this
228
+ * function exists to prevent, and it is a SILENT one: four translations
229
+ * disappeared from the payload and the build said nothing, because "no records
230
+ * for that collection" looks exactly like "no pages in that collection".
231
+ *
232
+ * Exact code first, then the first routed locale of the same language, in the
233
+ * order the site declares them. Nothing where the site routes that language at
234
+ * all — indexing it would offer a reader a URL that 404s.
235
+ *
236
+ * A CONSEQUENCE WORTH STATING: where a site routes two regions of one language
237
+ * — `pt-PT` and `pt-BR` over a single `pt/` tree — the records address the
238
+ * first, because the two URLs carry identical words and indexing both would
239
+ * double the index to say the same thing twice. A provider that wants to send a
240
+ * reader to their own region's spelling swaps the segment; duxt cannot, because
241
+ * it does not know who is searching.
242
+ */
243
+ function routedLocale(
244
+ locale: string,
245
+ options: DuxtSearchRecordOptions
246
+ ): string | undefined {
247
+ if (options.locales.includes(locale)) return locale;
248
+
249
+ const base = language(locale);
250
+ return options.locales.find((code) => language(code) === base);
251
+ }
252
+
253
+ /**
254
+ * Does this language get a segment in front of the path?
255
+ *
256
+ * The same question `llms-pages.ts` answers at request time, asked here against
257
+ * the build's own i18n options. `prefix_and_default` is the one that surprises:
258
+ * the default language is served at BOTH spellings, and the prefixed one is the
259
+ * canonical of the pair — so that is the one a search result should open.
260
+ */
261
+ function localeSegment(
262
+ routed: string,
263
+ options: DuxtSearchRecordOptions
264
+ ): string {
265
+ const strategy = options.strategy ?? 'prefix_except_default';
266
+ if (strategy === 'no_prefix') return '';
267
+ if (strategy === 'prefix_except_default' && routed === options.defaultLocale)
268
+ return '';
269
+ return `/${routed}`;
270
+ }
271
+
272
+ /**
273
+ * The source's own identity, as URL segments minus the version.
274
+ *
275
+ * Built from the two segments a source can claim rather than sliced out of
276
+ * `prefix`: a generated section's prefix is `<base><version?>/<slug>`, so the
277
+ * version sits in the MIDDLE of it and no suffix rule would survive a source
278
+ * that gained versions.
279
+ */
280
+ function identity(entry: DuxtResolvedSource): string {
281
+ const segments = [entry.repo, entry.generated?.slug].filter(Boolean);
282
+ return segments.length ? `/${segments.join('/')}` : '/';
283
+ }
284
+
285
+ /** Short enough to read in a log, long enough that a collision is not a risk. */
286
+ const identifier = (url: string) =>
287
+ createHash('sha256').update(url).digest('hex').slice(0, 32);
288
+
289
+ /**
290
+ * Every page of every source, version and language, as flat records.
291
+ *
292
+ * DETERMINISTIC ORDER, because the cache is read out of SQLite and SQLite
293
+ * promises none: sources in manifest order, pages by path inside a source, and
294
+ * sections in the order they are written. A provider diffing one build against
295
+ * the last must see a payload that moved only where the site did.
296
+ *
297
+ * FROZEN, because "read-only" is a rule and a rule nobody can break is better
298
+ * than a rule everybody is asked to keep. A consumer that sorts the array in
299
+ * place, or rewrites a title on the way into its own index, would be changing
300
+ * what every OTHER listener is handed — the second provider layer would inherit
301
+ * the first one's edits and nothing would say so.
302
+ */
303
+ export function duxtSearchRecords(
304
+ sources: readonly DuxtResolvedSource[],
305
+ pages: readonly CachedPage[],
306
+ options: DuxtSearchRecordOptions
307
+ ): readonly DuxtSearchRecord[] {
308
+ const records: DuxtSearchRecord[] = [];
309
+
310
+ const byCollection = new Map<string, CachedPage[]>();
311
+ for (const page of pages) {
312
+ const list = byCollection.get(page.collection);
313
+ if (list) list.push(page);
314
+ else byCollection.set(page.collection, [page]);
315
+ }
316
+
317
+ for (const entry of sources) {
318
+ const locale = entry.locale ?? options.defaultLocale;
319
+ const routed = routedLocale(locale, options);
320
+ if (!routed) continue;
321
+
322
+ const source = identity(entry);
323
+ const segment = localeSegment(routed, options);
324
+ const collected = byCollection.get(entry.collection);
325
+ if (!collected) continue;
326
+
327
+ for (const page of [...collected].sort((a, b) =>
328
+ a.path.localeCompare(b.path)
329
+ )) {
330
+ // The one predicate four other discovery surfaces already share: a page
331
+ // that wrote `search: false` is un-findable, not unpublished, and an
332
+ // external index is exactly the surface it meant.
333
+ if (!duxtPageSearchable(page.content)) continue;
334
+
335
+ for (const section of duxtSearchSections(page.content)) {
336
+ // Nothing to match on and nothing to show — an empty row in somebody
337
+ // else's index, charged for and never returned.
338
+ if (!section.title && !section.content) continue;
339
+
340
+ const url = `${segment}${page.path}${
341
+ section.anchor ? `#${section.anchor}` : ''
342
+ }`;
343
+
344
+ records.push(
345
+ Object.freeze({
346
+ id: identifier(url),
347
+ url,
348
+ title: section.title,
349
+ content: section.content,
350
+ source,
351
+ ...(entry.version ? { version: entry.version } : {}),
352
+ locale
353
+ })
354
+ );
355
+ }
356
+ }
357
+ }
358
+
359
+ return Object.freeze(records);
360
+ }
361
+
362
+ declare module '@nuxt/schema' {
363
+ interface NuxtHooks {
364
+ /**
365
+ * Every page of the site, as flat search records — duxt's one build-time
366
+ * extension point.
367
+ *
368
+ * Fires once per build, after Content has parsed every source, version,
369
+ * locale and generated section, and before Nitro prerenders or bundles
370
+ * anything — so a listener may still write into `public/`.
371
+ *
372
+ * READ-ONLY: the payload is frozen. Build or upload your own index from it;
373
+ * do not rewrite a record, reorder the array, or otherwise change what the
374
+ * next listener is handed.
375
+ */
376
+ 'duxt:search:records': (
377
+ records: readonly DuxtSearchRecord[]
378
+ ) => void | Promise<void>;
379
+ }
380
+ }
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A generated section's artefact, read off disk.
3
+ *
4
+ * The disk half of `DuxtSectionInput`, split out of `sections.ts` for the one
5
+ * reason that file is split from `sections-resolve.ts`: two callers need it and
6
+ * only one of them is allowed to import `@nuxt/content`. `sections.ts` builds
7
+ * the collections, `section-reports.ts` reads the same artefacts again for the
8
+ * manifest the modules hold, and both want the same handle over the same path.
9
+ *
10
+ * Nothing here knows what an artefact MEANS. It opens a file or walks a tree
11
+ * and hands the result to the type, which is the seam `DuxtSectionInput`
12
+ * documents: a resolver that understood a Bruno collection would have to
13
+ * understand the next type's tree as well.
14
+ */
15
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
16
+ import { join, relative, sep } from 'node:path';
17
+ import type { DuxtSectionInput, DuxtSectionType } from './sections-resolve';
18
+
19
+ /**
20
+ * Directories a walk never descends into.
21
+ *
22
+ * Not a taste call: `bruno.json`'s own `ignore` key defaults to exactly these
23
+ * two, and a docs repository that happens to have installed dependencies beside
24
+ * its collection would otherwise be read file by file into a section.
25
+ */
26
+ const SKIP = new Set(['.git', 'node_modules']);
27
+
28
+ /** What a type's `input` policy says the declared path is. */
29
+ export function sectionInputKind(type: DuxtSectionType): 'file' | 'directory' {
30
+ return type.input ?? 'file';
31
+ }
32
+
33
+ /**
34
+ * Whether the declared artefact is there, AS THE TYPE EXPECTS IT.
35
+ *
36
+ * A directory where a file was declared is as missing as nothing at all — the
37
+ * type would read it and find nothing — and saying so here is what lets
38
+ * `missingSectionArtefact` keep one message for both.
39
+ */
40
+ export function sectionArtefactExists(
41
+ absolute: string,
42
+ kind: 'file' | 'directory'
43
+ ): boolean {
44
+ if (!existsSync(absolute)) return false;
45
+
46
+ const stats = statSync(absolute);
47
+
48
+ return kind === 'directory' ? stats.isDirectory() : stats.isFile();
49
+ }
50
+
51
+ /**
52
+ * The artefact at `absolute`, as the type reads it.
53
+ *
54
+ * LAZY: nothing is opened until the type asks. A directory's listing is walked
55
+ * once and kept, because a type reads it more than once — a Bruno collection
56
+ * asks for `bruno.json`, then for every `folder.bru`, then for every request —
57
+ * and a second walk between two of those could see a different tree.
58
+ *
59
+ * `root` is the checkout `path` resolves against, and is PASSED RATHER THAN
60
+ * DERIVED from `absolute`: the caller already holds it, and subtracting one
61
+ * path from another is string arithmetic that goes wrong on the first symlink.
62
+ * Omitted for a remote checkout, which is what tells a type not to read its
63
+ * history — see `DuxtSectionInput.root`.
64
+ */
65
+ export function diskSectionInput(
66
+ absolute: string,
67
+ path: string,
68
+ kind: 'file' | 'directory',
69
+ root?: string
70
+ ): DuxtSectionInput {
71
+ if (kind === 'file') {
72
+ return {
73
+ path,
74
+ root,
75
+ kind,
76
+ text: () => readFileSync(absolute, 'utf8'),
77
+ files: () => [],
78
+ read: () => ''
79
+ };
80
+ }
81
+
82
+ let listing: string[] | undefined;
83
+
84
+ const files = () => (listing ??= walk(absolute));
85
+
86
+ return {
87
+ path,
88
+ root,
89
+ kind,
90
+ text: () => {
91
+ throw new Error(
92
+ `duxt: "${path}" is a directory, and this type asked it for a file.`
93
+ );
94
+ },
95
+ files,
96
+ read: (file) => {
97
+ // Read through the LISTING rather than straight off the path handed in:
98
+ // a type composing a name out of its own parsing could otherwise walk out
99
+ // of the section with `../`, and a documentation build that reads an
100
+ // arbitrary file off the machine it runs on is not a thing to leave open.
101
+ if (!files().includes(file)) return '';
102
+
103
+ return readFileSync(join(absolute, ...file.split('/')), 'utf8');
104
+ }
105
+ };
106
+ }
107
+
108
+ /**
109
+ * Every file under a directory, as `/`-separated relative paths, sorted.
110
+ *
111
+ * Sorted because a directory listing is not ordered: a section whose page order
112
+ * came from `readdir` would reorder itself on a different filesystem, and the
113
+ * URLs with it.
114
+ *
115
+ * Symlinks are not followed — `withFileTypes` reports one as neither a file nor
116
+ * a directory once `isSymbolicLink` is asked first — for the reason `read`
117
+ * checks its listing: a link out of the collection is a file this build has no
118
+ * business opening.
119
+ */
120
+ function walk(root: string): string[] {
121
+ const out: string[] = [];
122
+
123
+ const descend = (dir: string) => {
124
+ for (const item of readdirSync(dir, { withFileTypes: true })) {
125
+ if (item.isSymbolicLink()) continue;
126
+
127
+ const full = join(dir, item.name);
128
+
129
+ if (item.isDirectory()) {
130
+ if (SKIP.has(item.name)) continue;
131
+ descend(full);
132
+ continue;
133
+ }
134
+
135
+ if (item.isFile()) out.push(relative(root, full).split(sep).join('/'));
136
+ }
137
+ };
138
+
139
+ descend(root);
140
+
141
+ return out.sort();
142
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The small pieces of Markdown every generated section writes a page out of.
3
+ *
4
+ * The OpenAPI and Bruno generators each carried their own copy of all three,
5
+ * and the copies had started to differ: Bruno's first line undid inline
6
+ * Markdown with a pass of its own that was one refactor away from disagreeing
7
+ * with the reference's. One copy, so a page title reads the same whichever
8
+ * source produced it.
9
+ */
10
+
11
+ /** Zero-padded so ten pages still sort the way they read. */
12
+ export function pageOrder(index: number, total: number): string {
13
+ return String(index + 1).padStart(String(total).length, '0');
14
+ }
15
+
16
+ /**
17
+ * A URL segment: lowercase, no dots.
18
+ *
19
+ * The dots matter: Content reads a name made of digits and dots as a version
20
+ * and stops refining it, which would leave the `NN.` ordering prefix in the
21
+ * URL. `fallback` is what a name made only of punctuation becomes — empty by
22
+ * default, so a caller can try the next candidate with `||`.
23
+ */
24
+ export function urlSegment(value: string, fallback = ''): string {
25
+ return (
26
+ value
27
+ .toLowerCase()
28
+ .replace(/[^a-z0-9]+/g, '-')
29
+ .replace(/^-+|-+$/g, '') || fallback
30
+ );
31
+ }
32
+
33
+ /**
34
+ * The first paragraph of a description, as one line of PLAIN text.
35
+ *
36
+ * Plain matters because of where this goes: a page's `description`
37
+ * frontmatter, which becomes the meta description, the og tag and the card in
38
+ * a search result, and the summary on a group card. A CommonMark description
39
+ * carrying `*emphasis*` or a `[link](url)` renders as prose everywhere it is
40
+ * rendered as prose — and as its own punctuation everywhere it is not.
41
+ *
42
+ * Deliberately small. It undoes the four inline constructs a one-line summary
43
+ * actually meets — emphasis, code spans, links and images — and leaves
44
+ * everything else alone rather than growing into a second Markdown parser
45
+ * whose disagreements with the first would be invisible.
46
+ */
47
+ export function firstLine(value?: string): string | undefined {
48
+ const line = plain(value?.split(/\n\s*\n/)[0] ?? '')
49
+ .replace(/\s+/g, ' ')
50
+ .trim();
51
+
52
+ return line || undefined;
53
+ }
54
+
55
+ /** Inline Markdown, as the text it renders to. */
56
+ function plain(value: string): string {
57
+ return (
58
+ value
59
+ // An image before a link: `![alt](src)` is a link with a `!` in front, and
60
+ // taking the link first would leave the `!` behind.
61
+ .replaceAll(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
62
+ .replaceAll(/\[([^\]]*)\]\([^)]*\)/g, '$1')
63
+ .replaceAll(/`([^`]*)`/g, '$1')
64
+ .replaceAll(/(\*\*|__)(.+?)\1/g, '$2')
65
+ .replaceAll(/(\*|_)(.+?)\1/g, '$2')
66
+ );
67
+ }
@@ -1,4 +1,3 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
1
  import { join } from 'node:path';
3
2
  import type { DuxtResolvedSource } from './sources-resolve';
4
3
  import type { DuxtSectionTypes } from './sections-resolve';
@@ -7,6 +6,11 @@ import {
7
6
  missingSectionArtefact,
8
7
  sectionPages
9
8
  } from './sections-resolve';
9
+ import {
10
+ diskSectionInput,
11
+ sectionArtefactExists,
12
+ sectionInputKind
13
+ } from './section-input';
10
14
  import { repositoryRoot } from './repository-root';
11
15
 
12
16
  /**
@@ -38,14 +42,16 @@ export function readSectionReports(
38
42
 
39
43
  if (!type || entry.generated!.remote) continue;
40
44
 
41
- const file = join(repositoryRoot(), entry.path);
45
+ const root = repositoryRoot();
46
+ const file = join(root, entry.path);
47
+ const kind = sectionInputKind(type);
42
48
 
43
49
  // Both calls record the report on the entry; the return value is the
44
50
  // collection's business, not this one's. A missing local artefact throws
45
51
  // here exactly as it throws while the collections are declared — the same
46
52
  // error, from whichever of the two runs first.
47
- if (existsSync(file)) {
48
- sectionPages(entry, type, readFileSync(file, 'utf8'));
53
+ if (sectionArtefactExists(file, kind)) {
54
+ sectionPages(entry, type, diskSectionInput(file, entry.path, kind, root));
49
55
  } else {
50
56
  missingSectionArtefact(entry, file);
51
57
  }