@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
@@ -20,6 +20,16 @@ export interface DuxtSource {
20
20
  * default URL prefix without declaring two page collections for it.
21
21
  */
22
22
  content?: boolean;
23
+ /** Per-source conventions for draft pages and reusable Markdown blocks. */
24
+ exclude?: { drafts?: string; partials?: string };
25
+ /**
26
+ * The Markdown dialect this source was generated in.
27
+ *
28
+ * Dialects are layer-owned normalisers, not consumer-supplied transforms:
29
+ * one compact marker keeps an upstream repository readable without making
30
+ * its generator emit duxt-specific Markdown.
31
+ */
32
+ flavor?: DuxtSourceFlavor;
23
33
  /** `owner/name` or a full git URL. Omitted reads the local checkout. */
24
34
  repo?: string;
25
35
  /**
@@ -30,6 +40,15 @@ export interface DuxtSource {
30
40
  * build with "Could not find refs/heads/…".
31
41
  */
32
42
  refs?: DuxtRef[];
43
+ /**
44
+ * Discover release tags to publish as versions.
45
+ *
46
+ * Discovery is opt-in: a source still needs to say whether every release,
47
+ * one release per minor, or one per major belongs in its public URLs. Explicit
48
+ * `refs` stay available beside this object and override a discovered tag's
49
+ * label, lifecycle, default and locales.
50
+ */
51
+ releases?: DuxtSourceReleases;
33
52
  /**
34
53
  * Languages this source is available in, beyond the one written in `path`.
35
54
  *
@@ -64,6 +83,22 @@ export interface DuxtSource {
64
83
  version?: string;
65
84
  /** Shown in the version switcher and used in the URL; defaults to the ref. */
66
85
  label?: string;
86
+ /**
87
+ * What to CALL this source where the site names it to a reader.
88
+ *
89
+ * Display only — it never reaches a collection name, a URL prefix, a version
90
+ * or the ranking, which is what separates it from `label` and `slug`. Those
91
+ * two are addresses that happen to be readable; this is a name that is
92
+ * nothing else, so it is free to be prose and free to be translated.
93
+ *
94
+ * The one thing no rule can derive. A segment is an abbreviation as often as
95
+ * it is a word — `tf`, `sdk`, `api` — and a search result labelled with one
96
+ * tells a reader which URL they are in rather than which project. Unset, the
97
+ * ladder in `app/utils/search-display.ts` falls back to the segment and then
98
+ * to the site's own name, so a site that sets nothing is still never shown
99
+ * the bare `/` this key was added for.
100
+ */
101
+ name?: string | Record<string, string>;
67
102
  /**
68
103
  * Segment used in the URL for this source; defaults to the repository name.
69
104
  *
@@ -119,6 +154,9 @@ export interface DuxtSource {
119
154
  generated?: DuxtGeneratedSection[];
120
155
  }
121
156
 
157
+ /** Source dialects the layer understands. */
158
+ export type DuxtSourceFlavor = 'tfplugindocs';
159
+
122
160
  /**
123
161
  * Where a version sits in its life.
124
162
  *
@@ -156,6 +194,14 @@ export type DuxtRef =
156
194
  | ({ branch: string } & DuxtRefOptions)
157
195
  | ({ tag: string } & DuxtRefOptions);
158
196
 
197
+ /** The release lines a source publishes when it discovers Git tags. */
198
+ export interface DuxtSourceReleases {
199
+ /** Every release, or the newest release in each minor or major line. */
200
+ select: 'all' | 'minor' | 'major';
201
+ /** Include SemVer pre-releases; stable releases are the default. */
202
+ prereleases?: boolean;
203
+ }
204
+
159
205
  interface DuxtRefOptions {
160
206
  /** Shown in the switcher and used in the URL; defaults to the ref name. */
161
207
  label?: string;
@@ -269,6 +315,14 @@ export interface DuxtResolvedSource {
269
315
  repo?: string;
270
316
  /** Version label, when the list has more than one version. */
271
317
  version?: string;
318
+ /**
319
+ * The source's display name, as the consumer wrote it — see `DuxtSource`.
320
+ *
321
+ * Carried through UNRESOLVED, because a locale record cannot be collapsed at
322
+ * build time: the manifest is one object for every language the site serves,
323
+ * and `useDuxtConfig` resolves it per request like every other text field.
324
+ */
325
+ name?: string | Record<string, string>;
272
326
  /** True for the version served without a prefix. */
273
327
  isDefault: boolean;
274
328
  /**
@@ -298,6 +352,8 @@ export interface DuxtResolvedSource {
298
352
  status: DuxtSourceStatus;
299
353
  /** Whether the build may read this source's git history. */
300
354
  history: boolean;
355
+ /** The layer-owned Markdown dialect this collection is normalised from. */
356
+ flavor?: DuxtSourceFlavor;
301
357
  /**
302
358
  * Present when this collection is a GENERATED SECTION rather than a docs
303
359
  * tree — see `resolveGeneratedSections`.
@@ -646,6 +702,7 @@ export function resolveSources(
646
702
  prefix,
647
703
  repo: segmented(source) ? repoSlug(source) : undefined,
648
704
  version,
705
+ name: source.name,
649
706
  isDefault,
650
707
  repository: entry.repo ?? source.origin?.repo,
651
708
  repositoryUrl: entry.repo
@@ -673,7 +730,8 @@ export function resolveSources(
673
730
  'current',
674
731
  // A local source is a full checkout already; a remote one has to be
675
732
  // unshallowed, which is why it has to be asked for.
676
- history: entry.repo ? (source.history ?? false) : true
733
+ history: entry.repo ? (source.history ?? false) : true,
734
+ flavor: source.flavor
677
735
  });
678
736
  }
679
737
 
package/sources.ts CHANGED
@@ -17,6 +17,7 @@ export { repositoryRoot };
17
17
  export type {
18
18
  DuxtResolvedSource,
19
19
  DuxtSource,
20
+ DuxtSourceFlavor,
20
21
  DuxtSourcesOptions
21
22
  } from './sources-resolve';
22
23
  export { duxtSourceManifest } from './sources-resolve';
@@ -35,12 +36,81 @@ export { PARTIALS_COLLECTION, partialsCollection };
35
36
  export const pageSchema = z.object({
36
37
  /** Shown beside the entry in the sidebar, the section row and page cards. */
37
38
  icon: z.string().optional(),
38
- /** `landing` renders the page without the docs chrome. */
39
+ /** Preserved long enough for the tfplugindocs dialect to normalise it. */
40
+ page_title: z.string().optional(),
41
+ /** A tfplugindocs navigation group within its directory category. */
42
+ subcategory: z.string().optional(),
43
+ /** Set by a source dialect; useful to navigation consumers, not authors. */
44
+ category: z.string().optional(),
45
+ /** `landing` renders the page without the docs shell. */
39
46
  layout: z.string().optional(),
40
47
  /** The latest release of a generated changelog overview. */
41
48
  release: z.string().optional(),
42
- /** false hides the page from the navigation. */
43
- navigation: z.boolean().optional(),
49
+ //
50
+ // `navigation` is NOT declared here, deliberately. Content's own page schema
51
+ // already carries it — `boolean | { title, description, icon }`, defaulting
52
+ // to `true` — and a field declared here REPLACES that declaration rather than
53
+ // adding to it. Redeclaring it as a plain boolean cost two things at once: it
54
+ // dropped the object form, and it turned a TEXT column holding `'true'` into
55
+ // a BOOLEAN one holding NULL, which Content's own navigation query then
56
+ // compares against the string `'false'` and can never match. The page control
57
+ // that hides a page from the sidebar belongs to Content; the layer reads it
58
+ // and does not restate it.
59
+ //
60
+ // THE PAGE CONTROLS — the parts of the docs shell a page gets to refuse.
61
+ // What each one means, and how they compose, is `app/utils/page-controls.ts`;
62
+ // they are declared here because the schema is the gate, and an undeclared
63
+ // `toc: false` is dropped before the page component could ever read it. Flat
64
+ // fields rather than one nested object, which was decided: it is what an
65
+ // author writes in every comparable generator.
66
+ //
67
+ // EVERY BOOLEAN CONTROL CARRIES ITS DEFAULT, and that is not decoration.
68
+ // `z.boolean().optional()` generates a `BOOLEAN` column with no default, so a
69
+ // page that says nothing stores NULL — and Content casts a boolean column
70
+ // with `Boolean(value)` on the way back out, on every adapter. `Boolean(null)`
71
+ // is `false`, so every page read as though it had refused every control, and
72
+ // search returned nothing at all on every duxt site (#88). A default makes the
73
+ // column non-null, which is the only place the distinction survives: once the
74
+ // cast has run, "said nothing" and "said false" are the same value, and no
75
+ // predicate downstream can tell them apart. `tests/page-schema-database.test.ts`
76
+ // holds this by reading a page back out of a real database.
77
+ //
78
+ /**
79
+ * `false` drops the contents column; `{ maxDepth }` sets how deep it goes.
80
+ *
81
+ * `@nuxtjs/mdc` reads this same key out of the frontmatter and skips building
82
+ * `body.toc` at all on `false` — the same answer from the other end. The
83
+ * object form is ours alone: MDC's own `depth` is a count from `h2`, and
84
+ * `maxDepth` is the heading level an author actually counts.
85
+ *
86
+ * The one control with no default, because it needs none: a union is a JSON
87
+ * column, and Content leaves a JSON NULL alone rather than casting it — so an
88
+ * unanswered `toc` arrives as `null` and the site's own setting still wins.
89
+ */
90
+ toc: z
91
+ .union([z.boolean(), z.object({ maxDepth: z.number().optional() })])
92
+ .optional(),
93
+ /** false hides the trail above the title, whatever `duxt.breadcrumb` says. */
94
+ breadcrumb: z.boolean().default(true),
95
+ /** false hides the previous/next pair under the article. */
96
+ prevNext: z.boolean().default(true),
97
+ /** false hides the "was this helpful?" row. */
98
+ feedback: z.boolean().default(true),
99
+ /** false hides the edit link, the last-updated line and the contributors. */
100
+ pageInfo: z.boolean().default(true),
101
+ /** false hides the copy-page and hand-to-a-model control. */
102
+ copyPage: z.boolean().default(true),
103
+ /** true removes the reading-width cap, and nothing else. */
104
+ fullWidth: z.boolean().default(false),
105
+ /**
106
+ * false removes the page from every duxt-owned discovery surface — the client
107
+ * search, its fuzzy fallback, MCP `search_docs` and the two llms indexes.
108
+ *
109
+ * It changes no URL. The page is still served, still canonical, still in the
110
+ * sitemap, still `noindex`-free, and `read_page` still answers for it: this
111
+ * makes a page un-findable, not unpublished.
112
+ */
113
+ search: z.boolean().default(true),
44
114
  /**
45
115
  * URLs this page used to be served at. The layer turns them into redirects,
46
116
  * because it is the only thing that knows which prefixes exist — the
@@ -123,13 +193,46 @@ const DRAFTS = '**/*.draft.md';
123
193
  *
124
194
  * Excluded from the page collections themselves, or every partial would also
125
195
  * be a page — in the sidebar, in the search, in llms.txt.
196
+ *
197
+ * This is the EXCLUSION glob: the whole subtree, every file in it, whatever
198
+ * the extension. What the partials collection reads is derived from it by
199
+ * `partialsInclude` — the two are not the same glob.
126
200
  */
127
201
  const PARTIALS = '**/_partials/**';
128
202
 
203
+ /**
204
+ * The Markdown inside a partials subtree.
205
+ *
206
+ * One configured value answers two questions, and they do not take the same
207
+ * glob. A page collection has to lose the whole subtree — a screenshot beside
208
+ * a partial must not be read as a page either. The partials collection is
209
+ * itself `type: 'page'`, so it can only carry Markdown: handed the exclusion
210
+ * glob it swallows that same screenshot, and Content parses a PNG as a
211
+ * document. Conflating the two is the regression the `*.md` restriction has
212
+ * already been written once to prevent.
213
+ *
214
+ * Nothing is narrowed: every Markdown file the subtree holds, at whatever
215
+ * depth the consumer's own glob reaches, still lands in the collection. A glob
216
+ * that already names files (`**\/_partials/**\/*.mdc`) is left alone — it has
217
+ * made the restriction itself.
218
+ */
219
+ function partialsInclude(partials: string) {
220
+ const last = partials.slice(partials.lastIndexOf('/') + 1);
221
+ if (last.includes('.')) return partials;
222
+ if (partials.endsWith('**')) return `${partials}/*.md`;
223
+ if (partials.endsWith('*')) return `${partials}.md`;
224
+ return `${partials}/**/*.md`;
225
+ }
226
+
129
227
  /** The dev server shows drafts; a build does not. */
130
228
  const includeDrafts = () => process.env.NODE_ENV !== 'production';
131
229
 
132
- const excluded = () => (includeDrafts() ? [PARTIALS] : [PARTIALS, DRAFTS]);
230
+ const excluded = (source: DuxtSource) => {
231
+ const partials = source.exclude?.partials ?? PARTIALS;
232
+ const drafts = source.exclude?.drafts ?? DRAFTS;
233
+
234
+ return includeDrafts() ? [partials] : [partials, drafts];
235
+ };
133
236
 
134
237
  /**
135
238
  * Turn a compact source list into Content collections.
@@ -188,7 +291,7 @@ export function duxtSources(
188
291
  source: effective.repo
189
292
  ? {
190
293
  exclude: [
191
- ...excluded(),
294
+ ...excluded(source),
192
295
  ...nested.map((glob) => `${effective.path}/${glob}`)
193
296
  ],
194
297
  // A tag lives outside refs/heads, so it has to be passed as a tag —
@@ -202,7 +305,7 @@ export function duxtSources(
202
305
  prefix: entry.prefix
203
306
  }
204
307
  : {
205
- exclude: [...excluded(), ...nested],
308
+ exclude: [...excluded(source), ...nested],
206
309
  include: '**/*.md',
207
310
  cwd: join(repositoryRoot(), effective.path),
208
311
  prefix: entry.prefix
@@ -223,10 +326,13 @@ export function duxtSources(
223
326
  function partialFolders(
224
327
  resolved: DuxtResolvedSource[],
225
328
  expanded: ReturnType<typeof expandSources>
226
- ): Map<string | undefined, { repo?: string; path: string }[]> {
329
+ ): Map<
330
+ string | undefined,
331
+ { repo?: string; path: string; partials: string }[]
332
+ > {
227
333
  const byLocale = new Map<
228
334
  string | undefined,
229
- { repo?: string; path: string }[]
335
+ { repo?: string; path: string; partials: string }[]
230
336
  >();
231
337
  const seen = new Set<string>();
232
338
 
@@ -238,12 +344,13 @@ function partialFolders(
238
344
  // One entry per REPOSITORY AND FOLDER, not per version: a partial is a
239
345
  // block of prose, and reading three versions of it into one collection
240
346
  // would give three blocks under one name.
241
- const claim = `${key ?? ''}|${effective.repo ?? ''}:${effective.path}`;
347
+ const partials = source.exclude?.partials ?? PARTIALS;
348
+ const claim = `${key ?? ''}|${effective.repo ?? ''}:${effective.path}:${partials}`;
242
349
  if (seen.has(claim)) return;
243
350
  seen.add(claim);
244
351
 
245
352
  const list = byLocale.get(key) ?? [];
246
- list.push({ repo: effective.repo, path: effective.path });
353
+ list.push({ repo: effective.repo, path: effective.path, partials });
247
354
  byLocale.set(key, list);
248
355
  });
249
356
 
@@ -264,15 +371,17 @@ function partialFolders(
264
371
  * Two sources defining the same name is a collision the build reports rather
265
372
  * than resolves; see `modules/validate.ts`.
266
373
  */
267
- function definePartials(folders: { repo?: string; path: string }[]) {
374
+ function definePartials(
375
+ folders: { repo?: string; path: string; partials: string }[]
376
+ ) {
268
377
  const entries = folders.map((folder) =>
269
378
  folder.repo
270
379
  ? {
271
380
  repository: repoUrl(folder.repo),
272
- include: `${folder.path}/_partials/**/*.md`
381
+ include: `${folder.path}/${partialsInclude(folder.partials)}`
273
382
  }
274
383
  : {
275
- include: '_partials/**/*.md',
384
+ include: partialsInclude(folder.partials),
276
385
  cwd: join(repositoryRoot(), folder.path)
277
386
  }
278
387
  ) as NonNullable<Parameters<typeof defineCollection>[0]['source']>[];
@@ -0,0 +1,158 @@
1
+ import type { ContentNavigationItem } from '@nuxt/content';
2
+
3
+ /** The order Terraform Registry gives the generated provider categories. */
4
+ const CATEGORY_ORDER = [
5
+ 'index',
6
+ 'guides',
7
+ 'resources',
8
+ 'data-sources',
9
+ 'ephemeral-resources',
10
+ 'list-resources',
11
+ 'actions',
12
+ 'state-stores',
13
+ 'functions'
14
+ ] as const;
15
+
16
+ type TfplugindocsPage = Record<string, unknown>;
17
+ type NavigationItem = ContentNavigationItem & { subcategory?: string };
18
+
19
+ /**
20
+ * Bring tfplugindocs' Registry-oriented metadata into the ordinary page shape.
21
+ *
22
+ * The files remain ordinary Markdown pages: this only replaces the title a
23
+ * sidebar reads and carries the generator's directory category forward. The
24
+ * body is deliberately untouched, so a provider still renders on the Registry.
25
+ */
26
+ export function normaliseTfplugindocsPage(
27
+ page: TfplugindocsPage,
28
+ file: string
29
+ ) {
30
+ const category = categoryForFile(file);
31
+ const title =
32
+ headingTitle(page.title) ?? pageTitle(page.page_title) ?? fileName(file);
33
+
34
+ page.title = title;
35
+ page.category = category;
36
+ }
37
+
38
+ function headingTitle(value: unknown): string | undefined {
39
+ if (typeof value !== 'string' || !value.trim()) return undefined;
40
+
41
+ return value.replace(/\s+\([^)]*\)\s*$/, '').trim();
42
+ }
43
+
44
+ function pageTitle(value: unknown): string | undefined {
45
+ if (typeof value !== 'string' || !value.trim()) return undefined;
46
+
47
+ return (
48
+ value
49
+ // tfplugindocs writes `<name> <kind> - <provider>`; providers may call
50
+ // themselves `linear` as well as `terraform-provider-linear`.
51
+ .replace(/\s+-\s+[^\s]+\s*$/, '')
52
+ .replace(
53
+ /\s+(?:resource|data source|ephemeral resource|list resource|action|state store|function|provider)\s*$/i,
54
+ ''
55
+ )
56
+ .trim()
57
+ );
58
+ }
59
+
60
+ function fileName(file: string) {
61
+ return (
62
+ file
63
+ .split('/')
64
+ .at(-1)
65
+ ?.replace(/\.[^.]+$/, '') || 'index'
66
+ );
67
+ }
68
+
69
+ function categoryForFile(file: string) {
70
+ const parts = file.split('/').filter(Boolean);
71
+ const name = fileName(file);
72
+ return name === 'index' ? 'index' : (parts.at(-2) ?? 'index');
73
+ }
74
+
75
+ /**
76
+ * Shape a tfplugindocs collection's ordinary Content tree for provider docs.
77
+ *
78
+ * Content already makes the directory categories. Only their Registry order
79
+ * and tfplugindocs' frontmatter-only `subcategory` need layer work. Groups
80
+ * receive synthetic paths solely as stable Vue keys; they never become routes.
81
+ */
82
+ export function tfplugindocsNavigation(
83
+ tree: ContentNavigationItem[],
84
+ prefix: string
85
+ ): ContentNavigationItem[] {
86
+ return orderCategories(tree, prefix).map(groupSubcategories);
87
+ }
88
+
89
+ function orderCategories(
90
+ items: ContentNavigationItem[],
91
+ prefix: string
92
+ ): ContentNavigationItem[] {
93
+ return [...items]
94
+ .sort((left, right) => {
95
+ const difference =
96
+ categoryRank(left.path, prefix) - categoryRank(right.path, prefix);
97
+ return difference || left.title.localeCompare(right.title);
98
+ })
99
+ .map((item) => ({
100
+ ...item,
101
+ ...(item.children?.length
102
+ ? { children: orderCategories(item.children, prefix) }
103
+ : {})
104
+ }));
105
+ }
106
+
107
+ function categoryRank(path: string | undefined, prefix: string) {
108
+ if (path === prefix) return 0;
109
+
110
+ const category = path?.slice(prefix.length).split('/').filter(Boolean)[0];
111
+ const rank = CATEGORY_ORDER.indexOf(
112
+ category as (typeof CATEGORY_ORDER)[number]
113
+ );
114
+ return rank === -1 ? CATEGORY_ORDER.length + 1 : rank + 1;
115
+ }
116
+
117
+ function groupSubcategories(
118
+ item: ContentNavigationItem
119
+ ): ContentNavigationItem {
120
+ const children = item.children?.map(groupSubcategories);
121
+ if (!children?.length) return item;
122
+
123
+ const pages = children as NavigationItem[];
124
+ const grouped = new Map<string, NavigationItem[]>();
125
+ const direct: NavigationItem[] = [];
126
+
127
+ for (const child of pages) {
128
+ if (!child.subcategory) {
129
+ direct.push(child);
130
+ continue;
131
+ }
132
+
133
+ const group = grouped.get(child.subcategory) ?? [];
134
+ group.push(child);
135
+ grouped.set(child.subcategory, group);
136
+ }
137
+
138
+ if (!grouped.size) return { ...item, children };
139
+
140
+ const groups = [...grouped.entries()]
141
+ .sort(([left], [right]) => left.localeCompare(right))
142
+ .map(([title, members], index) => ({
143
+ title,
144
+ path: `${item.path ?? ''}/__duxt-subcategory-${index}`,
145
+ page: false as const,
146
+ children: [...members].sort((left, right) =>
147
+ left.title.localeCompare(right.title)
148
+ )
149
+ }));
150
+
151
+ return {
152
+ ...item,
153
+ children: [
154
+ ...direct.sort((left, right) => left.title.localeCompare(right.title)),
155
+ ...groups
156
+ ]
157
+ };
158
+ }