@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
package/README.md CHANGED
@@ -45,32 +45,38 @@ export default defineAppConfig({
45
45
  title: 'Acme',
46
46
  sources: [
47
47
  { path: 'docs', slug: 'acme' }, // this repository
48
- { repo: 'acme/api', path: 'docs', refs: ['main', 'v2.0.0', 'v1.4.0'] },
48
+ {
49
+ repo: 'acme/api',
50
+ path: 'docs',
51
+ releases: { select: 'minor' }, // newest tag per minor line
52
+ refs: [{ branch: 'main', status: 'upcoming' }],
53
+ },
49
54
  ],
50
- sourceOptions: { defaultRef: 'v2.0.0' },
51
55
  },
52
56
  })
53
57
  ```
54
58
 
55
- Two repositories and three refs become five collections, the URL prefixes that serve them, and a version switcher — none of which you write.
59
+ The local folder and every selected release or branch become collections, the URL prefixes that serve them, and a version switcher — none of which you write. Prefer a fixed list? Tags are explicit: `{ tag: 'v2.0.0' }`; a bare string names a branch.
56
60
 
57
61
  ## ✨ Features
58
62
 
59
63
  - **📦 Extend, don't scaffold** — a Nuxt layer: theme, pages, components and `app.config` defaults arrive with `extends` and are overridden file by file where you disagree.
60
- - **🗂️ Sources as a list** — one compact entry per source instead of one Content collection per version × repository.
64
+ - **🗂️ Sources and releases as a list** — one compact entry per source; select every release, or the newest per minor or major line, instead of maintaining one Content collection per version × repository.
61
65
  - **🧭 Version switcher and URL scheme** — `/[repo]/[version]/[...slug]`, collapsing cleanly when there is one source and no versions, with a lifecycle per version that decides the banner a reader gets.
62
66
  - **🌿 Git-native sourcing** — branches, tags, private repositories and hash-based caching come straight from Content v3's own `repository` support; `'latest'` resolves to the newest semver tag at build time.
63
- - **🤖 Machine-readable by default** — `llms.txt`, `llms-full.txt` and a real MCP server at `/mcp`, generated from the same collections the pages render from.
67
+ - **📚 Generated reference** — publish OpenAPI documents, Bruno collections and tfplugindocs trees beside authored Markdown; OpenAPI and Bruno can power the built-in request client.
68
+ - **🤖 Machine-readable by default** — `llms.txt`, the default-version corpus in `llms-full.txt`, every page as Markdown and a real MCP server at `/mcp`, all reading the same collections as the site.
64
69
  - **🎨 shadcn-vue theme, owned not vendored** — Tailwind 4 with the components in `app/components/ui/`, MDC components in `app/components/content/`.
65
- - **🔍 Search, TOC, breadcrumb, prev/next** — ⌘K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links.
70
+ - **🔍 Search, TOC, breadcrumb, prev/next** — ⌘/Ctrl+K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links.
66
71
  - **🧯 A build that fails loudly** — a validator walks the parse cache for URL collisions, empty collections, broken links and missing titles, because every bug this layer actually had was a silent one.
72
+ - **♻️ Cacheable build work** — remote Content downloads and rendered OG images expose stable cache keys and a warm-build report; see [Cache build work](docs/3.guides/12.cache-build-work.md).
67
73
 
68
74
  <details>
69
75
  <summary>Full feature list</summary>
70
76
 
71
77
  ### Sources & versions
72
78
 
73
- - **🗂️ Sources as a list** — one compact entry per source instead of one Content collection per version × repository. The single unversioned folder is the default and needs no config at all.
79
+ - **🗂️ Sources as a list** — one compact entry per source instead of one Content collection per version × repository. The single unversioned folder is the default and needs no config at all; `releases` discovers all releases or the newest per minor or major line.
74
80
  - **🧭 Version switcher and URL scheme** — `/[repo]/[version]/[...slug]`, collapsing cleanly when there is one source and no versions, with a lifecycle per version (`upcoming`, `current`, `maintained`, `deprecated`, `eol`) that decides the banner a reader gets.
75
81
  - **🌿 Git-native sourcing** — branches, tags, private repositories and hash-based caching come straight from Content v3's own `repository` support; `'latest'` resolves to the newest semver tag at build time.
76
82
  - **🔁 Redirects from frontmatter** — `redirectFrom` on a moved page becomes route rules under every repository, version and locale prefix it is served at.
@@ -78,12 +84,20 @@ Two repositories and three refs become five collections, the URL prefixes that s
78
84
  ### Theme & reading experience
79
85
 
80
86
  - **🎨 shadcn-vue theme, owned not vendored** — Tailwind 4 with the components in `app/components/ui/`, MDC components (`::callout`, `::steps`, `::code-group`, file trees, Mermaid) in `app/components/content/`.
81
- - **🔍 Search, TOC, breadcrumb, prev/next** — ⌘K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links.
87
+ - **🔍 Search, TOC, breadcrumb, prev/next** — ⌘/Ctrl+K fuzzy search across every source, active-heading tracking, section landing pages, feedback and "Edit this page" links.
82
88
  - **🌍 Seven locales out of the box** — `en-GB`, `en-US`, `de-DE`, `es-ES`, `fr-FR`, `pt-PT` and `pt-BR`; a site picks which of them it serves, and its own strings take a literal, a key or a per-locale record.
89
+ - **🖼️ Responsive images** — Nuxt Image produces responsive sources and a persistent zoom affordance while SVG and GIF pass through unchanged.
90
+
91
+ ### Generated reference
92
+
93
+ - **📄 OpenAPI** — turn a specification into navigable operation and schema pages, with an optional request client.
94
+ - **🧰 Bruno** — publish a request collection as reference pages and a downloadable redacted collection, with opt-in try-it controls.
95
+ - **🔌 tfplugindocs** — read a Terraform provider's generated documentation as a structured source with provider-aware navigation.
96
+ - **📜 Changelogs** — render a global or version-aligned release history, including the contributors in each release.
83
97
 
84
98
  ### Machine-readable & SEO
85
99
 
86
- - **🤖 Machine-readable by default** — `llms.txt`, `llms-full.txt` and a real MCP server at `/mcp` (list pages, read a page, search), generated from the same collections the pages render from.
100
+ - **🤖 Machine-readable by default** — `llms.txt`, the default-version corpus in `llms-full.txt`, every page as Markdown and a real MCP server at `/mcp` (`list_versions`, `list_pages`, `search_docs`, `read_page`), all reading the same collections as the site.
87
101
  - **📈 SEO and feeds included** — sitemap, robots, generated OG images, and an optional `/rss.xml` over a section you nominate.
88
102
 
89
103
  ### Layer & build
@@ -91,6 +105,7 @@ Two repositories and three refs become five collections, the URL prefixes that s
91
105
  - **📦 Extend, don't scaffold** — a Nuxt layer: theme, pages, components and `app.config` defaults arrive with `extends` and are overridden file by file where you disagree.
92
106
  - **🧯 A build that fails loudly** — a validator walks the parse cache for URL collisions, empty collections, broken links and missing titles, because every bug this layer actually had was a silent one.
93
107
  - **🔎 DevTools tab** — the resolved sources, collections, prefixes, message catalogues and download cache, in dev, where guessing used to be the only option.
108
+ - **♻️ Cacheable build work** — `duxt-cache-key` identifies remote Content downloads and `duxt-og-cache` identifies reusable rendered cards; the [cache guide](docs/3.guides/12.cache-build-work.md) wires both into CI.
94
109
 
95
110
  </details>
96
111
 
package/app/app.vue CHANGED
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { ConfigProvider } from 'reka-ui';
3
+
2
4
  /**
3
5
  * The locale's head tags.
4
6
  *
@@ -19,18 +21,11 @@
19
21
  */
20
22
  const { locale, locales } = useI18n();
21
23
  const duxt = useDuxtConfig();
24
+ const direction = useDuxtDirection();
22
25
 
23
26
  const baseUrl = (useRuntimeConfig().public as { i18n?: { baseUrl?: string } })
24
27
  .i18n?.baseUrl;
25
28
 
26
- const direction = computed(() => {
27
- const current = locales.value.find(
28
- (entry) => (typeof entry === 'string' ? entry : entry.code) === locale.value
29
- );
30
-
31
- return (typeof current === 'object' && current?.dir) || 'ltr';
32
- });
33
-
34
29
  useHead(() => ({
35
30
  htmlAttrs: { lang: locale.value, dir: direction.value },
36
31
 
@@ -116,7 +111,18 @@ useSchemaOrg([
116
111
  navigation; the focus half of the same problem is useDuxtPageFocus. -->
117
112
  <NuxtRouteAnnouncer />
118
113
 
119
- <NuxtLayout>
120
- <NuxtPage />
121
- </NuxtLayout>
114
+ <!-- reka-ui reads its direction from HERE and from nowhere else: its
115
+ `useDirection` injects this context and falls back to `ltr` without ever
116
+ consulting the document, so `<html dir>` alone leaves every menu, select,
117
+ tooltip and scroll area laid out left-to-right under a right-to-left
118
+ page. Renderless — it draws no element and changes no landmark. -->
119
+ <ConfigProvider :dir="direction">
120
+ <!-- One provider for every tooltip in the layer, including controls such as
121
+ DuxtLocale when a consumer renders them outside the stock header. -->
122
+ <UiTooltipProvider>
123
+ <NuxtLayout>
124
+ <NuxtPage />
125
+ </NuxtLayout>
126
+ </UiTooltipProvider>
127
+ </ConfigProvider>
122
128
  </template>
@@ -15,6 +15,38 @@
15
15
  sets it with classSuffix: '' so the two agree. */
16
16
  @custom-variant dark (&:is(.dark *));
17
17
 
18
+ /*
19
+ * TECHNICAL CONTENT IS LEFT-TO-RIGHT, whichever way the interface runs.
20
+ *
21
+ * A right-to-left locale turns the chrome round — the sidebar, the table of
22
+ * contents, the nesting rules, every `ms-`/`ps-`/`border-s` in this theme. It
23
+ * must not turn round a shell command, a file path, a URL or a JSON body:
24
+ * those are syntax, not prose, and the bidirectional algorithm reorders their
25
+ * punctuation into something that no longer parses. `cd ../src` rendered
26
+ * right-to-left is not a translation of `cd ../src`.
27
+ *
28
+ * `unicode-bidi: isolate` rather than `direction: ltr` alone, because the run
29
+ * has to be sealed off from the sentence AROUND it as well: an inline `code`
30
+ * sitting mid-paragraph inherits the paragraph's ordering otherwise, and a
31
+ * trailing bracket migrates to the wrong end of the word.
32
+ *
33
+ * NOT THE `[dir=rtl]` OVERRIDE SHEET THIS PROJECT REJECTED. That idea was a
34
+ * second, invisible layout system running beside the utility classes — every
35
+ * component having to remember it exists. This is the opposite: four element
36
+ * selectors saying what kind of content they hold, changing nothing in a
37
+ * left-to-right page, and needing no component to know about it at all.
38
+ *
39
+ * `:where()` throughout, so the whole thing carries no specificity and a
40
+ * consumer overrides it with a bare `code { … }`. The `[dir]` guard is the
41
+ * narrower escape hatch: a block that genuinely holds right-to-left text — a
42
+ * transliteration, a string literal in Arabic — says so on the element and is
43
+ * left alone.
44
+ */
45
+ :where(code, pre, kbd, samp):where(:not([dir])) {
46
+ direction: ltr;
47
+ unicode-bidi: isolate;
48
+ }
49
+
18
50
  /*
19
51
  * The neutral base palette, in oklch. A consumer overrides a token by
20
52
  * redefining it in its own stylesheet — no fork of this file needed.
@@ -246,7 +278,7 @@
246
278
  }
247
279
 
248
280
  .typeset :where(.duxt-anchor) {
249
- @apply absolute -left-5 font-normal text-muted-foreground opacity-0 transition-opacity;
281
+ @apply absolute -start-5 font-normal text-muted-foreground opacity-0 transition-opacity;
250
282
  }
251
283
 
252
284
  /* THE MARKER AS THE LINK ITSELF, where the heading already contains one and
@@ -256,7 +288,7 @@
256
288
  while the words beside it are the same link and not at all once they are
257
289
  somebody else's. */
258
290
  .typeset :where(.duxt-anchor-only) {
259
- @apply relative left-auto mr-2 inline no-underline opacity-60 transition-opacity;
291
+ @apply relative start-auto me-2 inline no-underline opacity-60 transition-opacity;
260
292
  }
261
293
 
262
294
  .typeset :where(.duxt-anchor-only:hover, .duxt-anchor-only:focus-visible) {
@@ -354,14 +386,14 @@ details[open] > .duxt-schema-row .duxt-schema-tile .duxt-schema-minus {
354
386
  because there the indent is what says it is nested. */
355
387
  .typeset :where(.duxt-release > ul) {
356
388
  margin: 0;
357
- padding-left: 0;
389
+ padding-inline-start: 0;
358
390
  list-style: none;
359
391
  }
360
392
 
361
393
  .typeset :where(.duxt-release > ul > li) {
362
394
  position: relative;
363
395
  margin: 0;
364
- padding-left: 1.05rem;
396
+ padding-inline-start: 1.05rem;
365
397
  line-height: 1.55;
366
398
  }
367
399
 
@@ -373,7 +405,7 @@ details[open] > .duxt-schema-row .duxt-schema-tile .duxt-schema-minus {
373
405
  content: '';
374
406
  position: absolute;
375
407
  top: 0.6em;
376
- left: 0;
408
+ inset-inline-start: 0;
377
409
  height: 0.3125rem;
378
410
  width: 0.3125rem;
379
411
  border-radius: 999px;
@@ -524,7 +556,16 @@ pre.shiki,
524
556
 
525
557
  /* Line numbers, opt-in per fence with ```ts line-numbers.
526
558
  A CSS counter rather than markup: the numbers are decoration, and a reader
527
- copying the block must not get them in the clipboard. */
559
+ copying the block must not get them in the clipboard.
560
+
561
+ PHYSICAL ON PURPOSE, and the only two declarations in this file that are.
562
+ `margin-right` and `text-align: right` sit inside `pre`/`code`, which the
563
+ `unicode-bidi: isolate` rule at the top of this file pins left-to-right —
564
+ a shell command is syntax, not prose. A gutter that followed the reader
565
+ would land on the opposite side of the run it numbers, and the digits
566
+ right-align towards that run for the same reason they do in an editor.
567
+ Declared with this reason in `tests/direction-utilities.test.ts`; every
568
+ other side-naming rule here is logical. */
528
569
  .duxt-line-numbers code {
529
570
  counter-reset: duxt-line;
530
571
  }
@@ -0,0 +1,117 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The site's own notices — a release, a migration window, an outage.
4
+ *
5
+ * NOT the version and translation banners beside it: those describe the page a
6
+ * reader is on and the layer decides when to draw them. This one says nothing
7
+ * until a consumer writes `duxt.announcements`, and then says exactly what they
8
+ * wrote.
9
+ *
10
+ * CLIENT-ONLY, and it has to be. Two of the three things this draws depend on
11
+ * the reader rather than on the build: whether they have already closed the
12
+ * notice, which lives in their browser, and whether the notice is inside its
13
+ * window, which is their clock. Every page here is prerendered, so a banner
14
+ * rendered at build time would carry a decision taken hours or weeks earlier —
15
+ * an announcement that expired overnight would still be in the HTML, and one a
16
+ * reader dismissed would flash back on every navigation before the script
17
+ * caught up.
18
+ */
19
+ const props = defineProps<{ placement: DuxtAnnouncementPlacement }>();
20
+
21
+ const { visible, dismiss } = useDuxtAnnouncements(props.placement);
22
+ const localeLink = useDuxtLink();
23
+
24
+ /**
25
+ * The two full-width placements belong to the header; the third to the page.
26
+ *
27
+ * `above-header` and `below-header` run to the browser edge and align their
28
+ * contents with the header above or below them, so the sentence starts where
29
+ * the site's name does. `above-content` sits in the document column with the
30
+ * version and translation banners, and takes their shape — a reader learns what
31
+ * a bordered notice over a page means once.
32
+ */
33
+ const edge = computed(() => props.placement !== 'above-content');
34
+
35
+ const strip = useTemplateRef<HTMLElement>('strip');
36
+
37
+ /**
38
+ * Dismiss, and then put focus somewhere a keyboard reader can carry on from.
39
+ *
40
+ * The button that was just activated leaves the DOM with the row it sat in, and
41
+ * a focused element that disappears drops focus onto `<body>` — which sends a
42
+ * screen reader back to the top of the document, several notices above where
43
+ * the reader actually was. So focus moves to whatever is left: the strip itself
44
+ * while it still holds an announcement, and the page's own main region once the
45
+ * last one is closed.
46
+ */
47
+ async function close(key: string) {
48
+ dismiss(key);
49
+ await nextTick();
50
+ (strip.value ?? document.getElementById('duxt-main'))?.focus();
51
+ }
52
+ </script>
53
+
54
+ <template>
55
+ <ClientOnly>
56
+ <!-- `role="status"` because the strip ARRIVES: it is drawn after hydration,
57
+ so a reader already reading the page is told once, politely, rather
58
+ than having a banner appear silently above them. -->
59
+ <div
60
+ v-if="visible.length"
61
+ ref="strip"
62
+ role="status"
63
+ tabindex="-1"
64
+ class="outline-none"
65
+ :class="
66
+ edge
67
+ ? 'w-full divide-y border-b bg-muted/40'
68
+ : 'mb-6 divide-y rounded-md border bg-muted/30'
69
+ "
70
+ >
71
+ <div
72
+ v-for="entry in visible"
73
+ :key="entry.key"
74
+ :class="
75
+ edge
76
+ ? 'mx-auto flex max-w-[90rem] items-start gap-3 px-4 py-2.5 lg:px-8'
77
+ : 'flex items-start gap-3 px-3 py-2.5'
78
+ "
79
+ >
80
+ <Icon
81
+ name="lucide:megaphone"
82
+ class="mt-0.5 size-4 shrink-0 text-muted-foreground"
83
+ />
84
+
85
+ <!-- `min-w-0` and `flex-wrap`, because the sentence is the part that
86
+ grows: a German or Portuguese translation of an English notice runs
87
+ half as long again, and on a phone it has to wrap under itself
88
+ rather than push the close button off the row. -->
89
+ <p
90
+ class="flex min-w-0 flex-1 flex-wrap items-baseline gap-x-2 gap-y-1 text-sm text-foreground text-pretty"
91
+ >
92
+ <span>{{ entry.text }}</span>
93
+
94
+ <NuxtLink
95
+ v-if="entry.link?.to"
96
+ :to="localeLink(entry.link.to)"
97
+ :target="entry.link.external ? '_blank' : undefined"
98
+ :rel="entry.link.external ? 'noopener' : undefined"
99
+ class="font-medium underline underline-offset-4"
100
+ >
101
+ {{ entry.link.label }}
102
+ </NuxtLink>
103
+ </p>
104
+
105
+ <UiButton
106
+ variant="ghost"
107
+ size="icon"
108
+ class="-my-1 -me-2 size-8 shrink-0 text-muted-foreground hover:text-foreground"
109
+ :aria-label="$t('duxt.announcement.dismiss')"
110
+ @click="close(entry.key)"
111
+ >
112
+ <Icon name="lucide:x" class="size-4" />
113
+ </UiButton>
114
+ </div>
115
+ </div>
116
+ </ClientOnly>
117
+ </template>
@@ -0,0 +1,62 @@
1
+ <script setup lang="ts">
2
+ import type { DuxtBrunoEntry } from '../../bruno-model';
3
+
4
+ /**
5
+ * A dictionary block — headers, query parameters, a form body — as a table.
6
+ *
7
+ * Two columns and no third, because a Bruno collection has no third to give: it
8
+ * carries a name and a value, and no type, no description and no statement of
9
+ * whether the field is required. A table with an empty "Description" column
10
+ * would be this type pretending to be `openapi`, which is the pretence the
11
+ * issue behind it rejected conversion for.
12
+ *
13
+ * TWO THINGS ARE MARKED. A `~` entry is switched off in the collection and is
14
+ * shown struck through rather than dropped — a reference that hid it would
15
+ * describe a request the file does not make. A withheld value shows the badge
16
+ * instead of the value: the name is the useful half (the endpoint wants an
17
+ * `Authorization` header) and the value was a live credential.
18
+ */
19
+ defineProps<{ entries?: DuxtBrunoEntry[] }>();
20
+ </script>
21
+
22
+ <template>
23
+ <table v-if="entries?.length" class="w-full text-start text-sm">
24
+ <thead>
25
+ <tr class="border-b border-border/60">
26
+ <th scope="col" class="py-2 pe-4 font-medium">
27
+ {{ $t('duxt.bruno.name') }}
28
+ </th>
29
+ <th scope="col" class="py-2 font-medium">
30
+ {{ $t('duxt.bruno.value') }}
31
+ </th>
32
+ </tr>
33
+ </thead>
34
+
35
+ <tbody>
36
+ <tr
37
+ v-for="entry in entries"
38
+ :key="`${entry.name}-${entry.value}`"
39
+ class="border-b border-border/40 last:border-b-0"
40
+ >
41
+ <td class="py-2 pe-4 align-top font-mono text-xs break-all">
42
+ <span :class="entry.disabled && 'text-muted-foreground line-through'">
43
+ {{ entry.name }}
44
+ </span>
45
+ </td>
46
+
47
+ <td class="py-2 align-top font-mono text-xs break-all">
48
+ <UiBadge v-if="entry.redacted" variant="secondary">
49
+ {{ $t('duxt.bruno.withheld') }}
50
+ </UiBadge>
51
+
52
+ <span
53
+ v-else
54
+ :class="entry.disabled && 'text-muted-foreground line-through'"
55
+ >
56
+ {{ entry.value }}
57
+ </span>
58
+ </td>
59
+ </tr>
60
+ </tbody>
61
+ </table>
62
+ </template>
@@ -64,32 +64,31 @@ const icon = computed(() =>
64
64
 
65
65
  const label = computed(() => props.filename ?? props.language);
66
66
 
67
- const copied = ref(false);
68
- const notify = useDuxtToast();
69
- const { t } = useI18n();
70
67
  const root = useTemplateRef<HTMLElement>('root');
68
+ const analytics = useDuxtAnalytics();
69
+ const { copied, copy: copyText } = useDuxtCopy();
71
70
 
72
71
  async function copy() {
73
72
  const text =
74
73
  props.code ?? root.value?.querySelector('code')?.textContent ?? '';
75
74
 
76
- try {
77
- await navigator.clipboard.writeText(text);
78
- copied.value = true;
79
- notify.success(t('duxt.code.copiedToast'));
80
- setTimeout(() => (copied.value = false), 2000);
81
- } catch {
82
- notify.error(
83
- 'Could not copy',
84
- 'The clipboard is unavailable in this context.'
85
- );
86
- // Clipboard is unavailable over plain HTTP; a failed copy stays silent.
75
+ // After the write, so a copy the clipboard refused is not reported as one —
76
+ // and the language only: the copied TEXT never travels.
77
+ if (await copyText(text)) {
78
+ analytics.track({ name: 'copy', kind: 'code', language: props.language });
87
79
  }
88
80
  }
89
81
  </script>
90
82
 
91
83
  <template>
84
+ <!-- `dir="ltr"` on the block, not on the `pre` alone: the header carries a
85
+ file name and the floating button sits over the code, and all three are
86
+ one technical surface. Inside the island the logical utilities below
87
+ resolve left-to-right, so they stay logical rather than turning physical
88
+ — the direction is declared once, in one place, instead of being spelled
89
+ out again in every class. -->
92
90
  <div
91
+ dir="ltr"
93
92
  class="duxt-code group relative my-6 overflow-hidden rounded-lg border bg-card"
94
93
  >
95
94
  <div
@@ -99,31 +98,16 @@ async function copy() {
99
98
  <Icon :name="icon" class="size-4 shrink-0" />
100
99
  <span class="truncate font-mono">{{ label }}</span>
101
100
 
102
- <UiButton
103
- variant="ghost"
104
- size="icon"
105
- class="ml-auto size-7 hover:bg-accent hover:text-foreground"
106
- :aria-label="copied ? $t('duxt.code.copied') : $t('duxt.code.copy')"
107
- @click="copy"
108
- >
109
- <Icon
110
- :name="copied ? 'lucide:check' : 'lucide:copy'"
111
- class="size-3.5"
112
- />
113
- </UiButton>
101
+ <DuxtCopyButton :copied="copied" class="ms-auto" @click="copy" />
114
102
  </div>
115
103
 
116
- <UiButton
104
+ <DuxtCopyButton
117
105
  v-else-if="header"
118
- variant="ghost"
119
- size="icon"
120
- class="duxt-code-copy absolute top-2 right-2 size-7 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-accent focus-visible:opacity-100"
106
+ :copied="copied"
107
+ class="duxt-code-copy absolute top-2 end-2 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100"
121
108
  :class="{ 'opacity-100': copied }"
122
- :aria-label="copied ? $t('duxt.code.copied') : $t('duxt.code.copy')"
123
109
  @click="copy"
124
- >
125
- <Icon :name="copied ? 'lucide:check' : 'lucide:copy'" class="size-3.5" />
126
- </UiButton>
110
+ />
127
111
 
128
112
  <div ref="root">
129
113
  <slot v-if="hasBody" />
@@ -0,0 +1,14 @@
1
+ <template>
2
+ <!-- The bar across the top of a code card: tabs or pills on one side, the
3
+ copy button on the other. Four cards draw it — the code group, the
4
+ preview, the package managers and the request samples — and a reader
5
+ learns what that bar does once only if it is the same bar each time.
6
+
7
+ A plain box, never the tablist itself: `role="tablist"` may only contain
8
+ tabs, and the copy button beside them is not one. -->
9
+ <div
10
+ class="flex min-h-11 items-center gap-1 border-b bg-muted/40 px-2 py-1.5"
11
+ >
12
+ <slot />
13
+ </div>
14
+ </template>
@@ -0,0 +1,33 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue';
3
+ import { cn } from '@duxt/lib/utils';
4
+
5
+ /**
6
+ * The icon button beside a block of code: a copy mark that turns into a check.
7
+ *
8
+ * Its state comes from `useDuxtCopy` in the component that owns the text —
9
+ * this only draws it, because what gets copied is the owner's to know (a fence's
10
+ * source, the tab that is showing, the manager that is picked).
11
+ */
12
+ const props = defineProps<{
13
+ copied: boolean;
14
+ /** The accessible name before copying. Defaults to "Copy code". */
15
+ label?: string;
16
+ class?: HTMLAttributes['class'];
17
+ }>();
18
+ </script>
19
+
20
+ <template>
21
+ <UiButton
22
+ variant="ghost"
23
+ size="icon"
24
+ :class="
25
+ cn('size-7 shrink-0 hover:bg-accent hover:text-foreground', props.class)
26
+ "
27
+ :aria-label="
28
+ copied ? $t('duxt.code.copied') : (label ?? $t('duxt.code.copy'))
29
+ "
30
+ >
31
+ <Icon :name="copied ? 'lucide:check' : 'lucide:copy'" class="size-3.5" />
32
+ </UiButton>
33
+ </template>
@@ -1,4 +1,6 @@
1
1
  <script setup lang="ts">
2
+ import { stripFrontmatter } from '../../frontmatter';
3
+
2
4
  /**
3
5
  * The top action: hand this page to a model, or read its source.
4
6
  *
@@ -20,14 +22,19 @@ const props = defineProps<{
20
22
  }>();
21
23
 
22
24
  const { t } = useI18n();
23
- const notify = useDuxtToast();
24
25
  const localeLink = useDuxtLink();
26
+ const duxt = useDuxtConfig();
25
27
 
26
- const copied = ref(false);
28
+ const { copied, copy: copyText } = useDuxtCopy({
29
+ // The button's own label turns into "Copied"; a toast would say it twice.
30
+ toast: false,
31
+ failureKey: 'duxt.page.copyFailed'
32
+ });
27
33
 
28
34
  const markdownPath = computed(
29
35
  () => `${localeLink(props.path) ?? props.path}.md`
30
36
  );
37
+ const models = computed(() => duxt.copy?.models ?? []);
31
38
 
32
39
  /** The prompt both models get: fetch the source, then answer about it. */
33
40
  function prompt() {
@@ -36,21 +43,10 @@ function prompt() {
36
43
  return t('duxt.page.copy.prompt', { url, title: props.title ?? '' });
37
44
  }
38
45
 
39
- async function copy() {
40
- const raw = props.rawbody;
41
- if (!raw) return;
42
-
46
+ function copy() {
43
47
  // Without the frontmatter: what is copied should read as the page, not as
44
48
  // the file. Same call `llms-full.txt` makes over the same field.
45
- const text = raw.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
46
-
47
- try {
48
- await navigator.clipboard.writeText(text);
49
- copied.value = true;
50
- setTimeout(() => (copied.value = false), 2000);
51
- } catch {
52
- notify.error(t('duxt.page.copyFailed'));
53
- }
49
+ return copyText(props.rawbody && stripFrontmatter(props.rawbody));
54
50
  }
55
51
 
56
52
  function open(base: string) {
@@ -63,11 +59,18 @@ function open(base: string) {
63
59
  </script>
64
60
 
65
61
  <template>
66
- <div v-if="rawbody" class="flex shrink-0 items-stretch">
62
+ <!-- `data-duxt-copy-page` is how `check:overflow` finds this control on a
63
+ built page. It is not styling and not behaviour: matching on the utility
64
+ classes instead would make any restyle silently stop checking anything,
65
+ and matching on the button's own words would tie a gate to a translation
66
+ string. `shrink-0` is deliberate — this holds a dropdown trigger and a
67
+ link, and a squeezed control is worse than a wrapped one — so the row
68
+ that carries it is the thing that has to give (see `[...slug].vue`). -->
69
+ <div v-if="rawbody" data-duxt-copy-page class="flex shrink-0 items-stretch">
67
70
  <UiButton
68
71
  variant="outline"
69
72
  size="sm"
70
- class="gap-1.5 rounded-r-none border-r-0"
73
+ class="gap-1.5 rounded-e-none border-e-0"
71
74
  @click="copy"
72
75
  >
73
76
  <Icon :name="copied ? 'lucide:check' : 'lucide:copy'" class="size-3.5" />
@@ -79,7 +82,7 @@ function open(base: string) {
79
82
  <UiButton
80
83
  variant="outline"
81
84
  size="sm"
82
- class="rounded-l-none px-1.5"
85
+ class="rounded-s-none px-1.5"
83
86
  :aria-label="$t('duxt.page.copy.more')"
84
87
  >
85
88
  <Icon name="lucide:chevron-down" class="size-3.5 opacity-60" />
@@ -94,22 +97,21 @@ function open(base: string) {
94
97
  </a>
95
98
  </UiDropdownMenuItem>
96
99
 
97
- <UiDropdownMenuSeparator />
98
-
99
- <UiDropdownMenuItem
100
- class="gap-2"
101
- @select="open('https://chatgpt.com/?q=')"
102
- >
103
- <Icon name="simple-icons:openai" class="size-4" />
104
- {{ $t('duxt.page.copy.chatgpt') }}
105
- </UiDropdownMenuItem>
100
+ <!--
101
+ Only when there is a second group to divide: `copy.models` is a
102
+ consumer-owned list, and an empty one would otherwise leave a rule
103
+ drawn under the last item with nothing beneath it.
104
+ -->
105
+ <UiDropdownMenuSeparator v-if="models.length" />
106
106
 
107
107
  <UiDropdownMenuItem
108
+ v-for="model in models"
109
+ :key="model.url"
108
110
  class="gap-2"
109
- @select="open('https://claude.ai/new?q=')"
111
+ @select="open(model.url)"
110
112
  >
111
- <Icon name="simple-icons:claude" class="size-4" />
112
- {{ $t('duxt.page.copy.claude') }}
113
+ <Icon :name="model.icon" class="size-4" />
114
+ {{ model.label }}
113
115
  </UiDropdownMenuItem>
114
116
  </UiDropdownMenuContent>
115
117
  </UiDropdownMenu>