@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
@@ -105,14 +105,65 @@ export function useDuxtVersion() {
105
105
  () => isPreferred.value && status.value !== 'eol'
106
106
  );
107
107
 
108
- /** The same page in the preferred version, for the banner and the canonical. */
109
- const preferredPath = computed(() =>
110
- versionPath(
108
+ /**
109
+ * Whether the same page is MISSING from the preferred version.
110
+ *
111
+ * Only a changelog release page can be: every version publishes its own
112
+ * changelog, and a branch's may carry a release the default version has not
113
+ * shipped. Swapping the prefix alone then pointed the banner's link and the
114
+ * canonical at a page that does not exist. Every other per-version tree is the
115
+ * same tree at another ref, so nothing else is looked up.
116
+ *
117
+ * `useAsyncData`, so the server waits for it and the canonical in the
118
+ * prerendered HTML is already right. Keyed per path, so the banner and the
119
+ * page share one lookup. Nothing after the `await` touches a composable.
120
+ */
121
+ const { data: preferredMissing } = useAsyncData(
122
+ () => `duxt-preferred-missing-${path.value}`,
123
+ async () => {
124
+ const own = current.value;
125
+ const target = preferred.value;
126
+ if (
127
+ !own ||
128
+ !target ||
129
+ own === target ||
130
+ own.generated?.type !== 'changelog' ||
131
+ path.value === own.prefix
132
+ ) {
133
+ return false;
134
+ }
135
+
136
+ const candidate = versionPath(
137
+ path.value,
138
+ own.prefix,
139
+ target.prefix || '/'
140
+ );
141
+ const hit = await queryCollection(target.collection as DuxtCollectionArg)
142
+ .path(candidate)
143
+ .select('path')
144
+ .first();
145
+
146
+ return !hit;
147
+ },
148
+ { default: () => false, watch: [path] }
149
+ );
150
+
151
+ /**
152
+ * The same page in the preferred version, for the banner and the canonical —
153
+ * or that version's own root where it never published the page. The same rule
154
+ * the switcher follows, see `versionSwitchPath`.
155
+ */
156
+ const preferredPath = computed(() => {
157
+ const to = preferred.value?.prefix || '/';
158
+ const candidate = versionPath(path.value, current.value?.prefix, to);
159
+
160
+ return versionSwitchPath(
111
161
  path.value,
112
162
  current.value?.prefix,
113
- preferred.value?.prefix || '/'
114
- )
115
- );
163
+ to,
164
+ new Set(preferredMissing.value ? [candidate] : [])
165
+ );
166
+ });
116
167
 
117
168
  const shouldWarn = computed(
118
169
  () =>
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Run a measurement whenever the window scrolls or resizes — at most once per
3
+ * painted frame.
4
+ *
5
+ * The reading-progress bar and the table of contents both answer a question
6
+ * about where the reader is, and both used to wire up the same two listeners by
7
+ * hand; only one of them coalesced into an animation frame, so the other ran
8
+ * its layout read on every scroll event however fast the wheel turned.
9
+ *
10
+ * Passive listeners, because this must never be what makes scrolling stutter.
11
+ * Measured once on mount, since a page can open already scrolled. Returns the
12
+ * scheduler, for a caller that knows of another moment worth measuring.
13
+ */
14
+ import { useEventListener } from '@vueuse/core';
15
+
16
+ export function useDuxtViewportMeasure(measure: () => void) {
17
+ let frame = 0;
18
+
19
+ function schedule() {
20
+ if (frame) return;
21
+ frame = requestAnimationFrame(() => {
22
+ frame = 0;
23
+ measure();
24
+ });
25
+ }
26
+
27
+ // `useEventListener` without a target binds to the window, and to nothing at
28
+ // all during server rendering.
29
+ useEventListener(['scroll', 'resize'], schedule, { passive: true });
30
+
31
+ onMounted(measure);
32
+ onBeforeUnmount(() => {
33
+ if (frame) cancelAnimationFrame(frame);
34
+ });
35
+
36
+ return schedule;
37
+ }
@@ -31,7 +31,12 @@ export function useFuzzySearch(
31
31
  * the approximate pass reading the same one.
32
32
  */
33
33
  collection: MaybeRefOrGetter<DuxtCollectionName> = 'docs' as DuxtCollectionName,
34
- options?: { ignoredTags?: string[] }
34
+ options?: {
35
+ ignoredTags?: string[];
36
+ threshold?: number;
37
+ minMatchCharLength?: number;
38
+ limit?: number;
39
+ }
35
40
  ) {
36
41
  let index: Promise<Fuse<DuxtSearchSection>> | undefined;
37
42
  let indexedFor: string | undefined;
@@ -58,12 +63,12 @@ export function useFuzzySearch(
58
63
 
59
64
  // Loose enough to survive a transposed letter, tight enough that a short
60
65
  // term does not drag in half the documentation.
61
- threshold: 0.35,
62
- minMatchCharLength: 3
66
+ threshold: options?.threshold ?? 0.35,
67
+ minMatchCharLength: options?.minMatchCharLength ?? 3
63
68
  });
64
69
  }
65
70
 
66
- async function search(term: string, limit = 20) {
71
+ async function search(term: string, limit = options?.limit ?? 20) {
67
72
  const name = toValue(collection);
68
73
 
69
74
  // Built on first use, and again when the route moves to another collection
@@ -1,7 +1,5 @@
1
1
  /** Where the list is kept. Namespaced so a consumer's own keys cannot collide. */
2
2
  const STORAGE_KEY = 'duxt:recent-pages';
3
- const LIMIT = 5;
4
-
5
3
  export interface RecentPage {
6
4
  path: string;
7
5
  title: string;
@@ -15,17 +13,19 @@ export interface RecentPage {
15
13
  * request. It stays in the browser, identifies nobody, and clearing site data
16
14
  * forgets it.
17
15
  */
18
- export function useRecentPages() {
16
+ export function useRecentPages(limit = 5) {
19
17
  const recent = useState<RecentPage[]>('duxt-recent-pages', () => []);
20
18
 
21
19
  function read(): RecentPage[] {
22
- try {
23
- const stored = window.localStorage.getItem(STORAGE_KEY);
24
- return stored ? (JSON.parse(stored) as RecentPage[]) : [];
25
- } catch {
26
- // Blocked storage, or a value someone else wrote — either way, no history.
27
- return [];
28
- }
20
+ // Blocked storage, or a value someone else wrote — either way, no history.
21
+ const parsed = readStoredJson(STORAGE_KEY);
22
+
23
+ return Array.isArray(parsed)
24
+ ? parsed.filter(
25
+ (entry): entry is RecentPage =>
26
+ typeof entry?.path === 'string' && typeof entry?.title === 'string'
27
+ )
28
+ : [];
29
29
  }
30
30
 
31
31
  function remember(page: RecentPage) {
@@ -34,18 +34,15 @@ export function useRecentPages() {
34
34
  const next = [
35
35
  page,
36
36
  ...read().filter((entry) => entry.path !== page.path)
37
- ].slice(0, LIMIT);
37
+ ].slice(0, Math.max(0, limit));
38
38
  recent.value = next;
39
39
 
40
- try {
41
- window.localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
42
- } catch {
43
- // A convenience not worth an error.
44
- }
40
+ // A convenience not worth an error.
41
+ writeStoredJson(STORAGE_KEY, next);
45
42
  }
46
43
 
47
44
  function load() {
48
- if (import.meta.client) recent.value = read();
45
+ if (import.meta.client) recent.value = read().slice(0, Math.max(0, limit));
49
46
  }
50
47
 
51
48
  return { recent, remember, load };
package/app/error.vue CHANGED
@@ -1,5 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  import type { NuxtError } from '#app';
3
+ import { ConfigProvider } from 'reka-ui';
3
4
 
4
5
  const props = defineProps<{ error: NuxtError }>();
5
6
 
@@ -7,16 +8,27 @@ const localeLink = useDuxtLink();
7
8
  const path = useDuxtPath();
8
9
  const duxt = useDuxtConfig();
9
10
  const { locale } = useI18n();
11
+ const direction = useDuxtDirection();
10
12
 
11
13
  /**
12
14
  * The error page replaces `app.vue`, so it inherits nothing from it — not the
13
- * `lang` attribute and not the title template. Both have to be set again here,
14
- * or the one page a reader reaches by accident is the one page with an
15
- * untitled, language-less document.
15
+ * `lang` attribute, not the `dir` one and not the title template. All three
16
+ * have to be set again here, or the one page a reader reaches by accident is
17
+ * the one page with an untitled, language-less, wrong-way-round document.
18
+ *
19
+ * `dir` was the one that was missing, which is the whole shape of the problem:
20
+ * nothing failed, the error page simply stayed left-to-right while the rest of
21
+ * the site turned round.
16
22
  */
17
23
  useHead(() => ({
18
- htmlAttrs: { lang: locale.value },
19
- title: `${props.error.statusCode} · ${duxt.title}`
24
+ htmlAttrs: { lang: locale.value, dir: direction.value },
25
+ // The status code alone, through the same template `app.vue` sets. After a
26
+ // client-side navigation that template is still mounted, so a title that
27
+ // already carried the site name came out as "404 · duxt · duxt"; rendered on
28
+ // the server it is not, and this page has to bring its own.
29
+ title: String(props.error.statusCode),
30
+ titleTemplate: (title?: string) =>
31
+ title ? `${title} · ${duxt.title}` : duxt.title
20
32
  }));
21
33
 
22
34
  /**
@@ -39,25 +51,20 @@ useSeoMeta({ robots: 'noindex, follow' });
39
51
  */
40
52
  const { data: navigation } = await useDuxtNavigation();
41
53
 
54
+ // `flattenedNavigationPages` rather than a walk of its own: it honours
55
+ // `page: false`, and a group that is not a route — a tfplugindocs `subcategory`
56
+ // is the standing example — was being offered here as a near miss, which is a
57
+ // 404 suggesting another 404.
42
58
  const suggestions = computed(() => {
43
59
  if (props.error.statusCode !== 404) return [];
44
60
 
45
- const pages: { path: string; title?: string }[] = [];
46
-
47
- const walk = (
48
- items: { path?: string; title?: string; children?: unknown[] }[]
49
- ) => {
50
- for (const item of items) {
51
- if (item.path) pages.push({ path: item.path, title: item.title });
52
- if (Array.isArray(item.children)) {
53
- walk(item.children as { path?: string; title?: string }[]);
54
- }
55
- }
56
- };
57
-
58
- walk((navigation.value ?? []) as { path?: string; title?: string }[]);
59
-
60
- return nearestPages(path.value, pages);
61
+ return nearestPages(
62
+ path.value,
63
+ flattenedNavigationPages(navigation.value ?? []).map((item) => ({
64
+ path: item.path,
65
+ title: item.title
66
+ }))
67
+ );
61
68
  });
62
69
 
63
70
  // A page missing from one version but present in another is the interesting
@@ -73,61 +80,92 @@ const elsewhere = computed(
73
80
  </script>
74
81
 
75
82
  <template>
76
- <NuxtLayout>
77
- <div
78
- class="mx-auto flex max-w-2xl flex-col items-center px-4 py-32 text-center"
79
- >
80
- <p class="font-mono text-sm text-muted-foreground">
81
- {{ error.statusCode }}
82
- </p>
83
- <h1 class="mt-3 text-3xl font-semibold tracking-tight text-balance">
84
- {{ error.statusMessage ?? $t('duxt.error.title') }}
85
- </h1>
83
+ <!-- The same provider `app.vue` mounts, for the same reason: this page
84
+ replaces that one, so the reka primitives inside the layout below would
85
+ otherwise be left with the `ltr` default. The tooltip provider likewise:
86
+ the header's icon controls carry tooltips, and without one a client-side
87
+ navigation to this page threw before it could draw. -->
88
+ <ConfigProvider :dir="direction">
89
+ <UiTooltipProvider>
90
+ <NuxtLayout>
91
+ <div
92
+ class="mx-auto flex max-w-2xl flex-col items-center px-4 py-32 text-center"
93
+ >
94
+ <p class="font-mono text-sm text-muted-foreground">
95
+ {{ error.statusCode }}
96
+ </p>
97
+ <h1 class="mt-3 text-3xl font-semibold tracking-tight text-balance">
98
+ {{ error.statusMessage ?? $t('duxt.error.title') }}
99
+ </h1>
86
100
 
87
- <div v-if="elsewhere.length" class="mt-8 w-full">
88
- <p class="mb-3 text-sm text-muted-foreground">
89
- {{ $t('duxt.error.elsewhere') }}
90
- </p>
91
- <div class="flex flex-wrap justify-center gap-2">
92
- <UiButton
93
- v-for="entry in elsewhere"
94
- :key="entry.path"
95
- as-child
96
- variant="outline"
97
- size="sm"
98
- >
99
- <NuxtLink :to="localeLink(entry.path)" class="font-mono text-xs">
100
- {{ entry.version.label }}
101
+ <div v-if="elsewhere.length" class="mt-8 w-full">
102
+ <p class="mb-3 text-sm text-muted-foreground">
103
+ {{ $t('duxt.error.elsewhere') }}
104
+ </p>
105
+ <div class="flex flex-wrap justify-center gap-2">
106
+ <UiButton
107
+ v-for="entry in elsewhere"
108
+ :key="entry.path"
109
+ as-child
110
+ variant="outline"
111
+ size="sm"
112
+ >
113
+ <NuxtLink
114
+ :to="localeLink(entry.path)"
115
+ class="font-mono text-xs"
116
+ >
117
+ {{ entry.version.label }}
118
+ </NuxtLink>
119
+ </UiButton>
120
+ </div>
121
+ </div>
122
+
123
+ <div v-if="suggestions.length" class="mt-8 w-full">
124
+ <p class="mb-3 text-sm text-muted-foreground">
125
+ {{ $t('duxt.error.nearest') }}
126
+ </p>
127
+ <!-- ROWS, NOT A CENTRED LINE PER PAGE. Each suggestion used to be a
128
+ title and its path run together on one centred line, so every
129
+ row started somewhere else and the paths wandered from line to
130
+ line. Left-aligned in one column — title over path, the way the
131
+ search dialog lists a page — the list reads down at a glance. -->
132
+ <ul class="mx-auto flex w-full max-w-md flex-col gap-1 text-start">
133
+ <li v-for="page in suggestions" :key="page.path">
134
+ <NuxtLink
135
+ :to="localeLink(page.path)"
136
+ class="group flex items-center gap-3 rounded-md px-3 py-2 transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
137
+ >
138
+ <Icon
139
+ name="lucide:file-text"
140
+ class="size-4 shrink-0 text-muted-foreground"
141
+ />
142
+ <span class="min-w-0 flex-1">
143
+ <span class="block truncate text-sm font-medium">
144
+ {{ page.title ?? page.path }}
145
+ </span>
146
+ <span
147
+ class="block truncate font-mono text-xs text-muted-foreground"
148
+ >
149
+ {{ page.path }}
150
+ </span>
151
+ </span>
152
+ <Icon
153
+ name="lucide:chevron-right"
154
+ class="size-4 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover:opacity-100 group-focus-visible:opacity-100 rtl:-scale-x-100"
155
+ />
156
+ </NuxtLink>
157
+ </li>
158
+ </ul>
159
+ </div>
160
+
161
+ <UiButton as-child class="mt-10">
162
+ <NuxtLink :to="localeLink('/')">
163
+ <Icon name="lucide:arrow-left" class="size-4 rtl:-scale-x-100" />
164
+ {{ $t('duxt.error.back') }}
101
165
  </NuxtLink>
102
166
  </UiButton>
103
167
  </div>
104
- </div>
105
-
106
- <div v-if="suggestions.length" class="mt-8 w-full">
107
- <p class="mb-3 text-sm text-muted-foreground">
108
- {{ $t('duxt.error.nearest') }}
109
- </p>
110
- <ul class="flex flex-col gap-1 text-sm">
111
- <li v-for="page in suggestions" :key="page.path">
112
- <NuxtLink
113
- :to="localeLink(page.path)"
114
- class="flex items-center justify-center gap-2 rounded-md px-3 py-1.5 transition-colors hover:bg-accent"
115
- >
116
- <span class="font-medium">{{ page.title ?? page.path }}</span>
117
- <span class="font-mono text-xs text-muted-foreground">{{
118
- page.path
119
- }}</span>
120
- </NuxtLink>
121
- </li>
122
- </ul>
123
- </div>
124
-
125
- <UiButton as-child class="mt-10">
126
- <NuxtLink :to="localeLink('/')">
127
- <Icon name="lucide:arrow-left" class="size-4" />
128
- {{ $t('duxt.error.back') }}
129
- </NuxtLink>
130
- </UiButton>
131
- </div>
132
- </NuxtLayout>
168
+ </NuxtLayout>
169
+ </UiTooltipProvider>
170
+ </ConfigProvider>
133
171
  </template>
@@ -50,16 +50,24 @@ const { headerOffset } = useDuxtSectionRow();
50
50
  <DuxtProgress />
51
51
  <DuxtShortcuts />
52
52
 
53
+ <!-- The site the layer is rendering may have something to say. Both strips
54
+ draw nothing at all until `duxt.announcements` holds one, space
55
+ included; the third placement, `above-content`, belongs to the page
56
+ rather than to the shell. -->
57
+ <DuxtAnnouncements placement="above-header" />
58
+
53
59
  <DuxtHeader />
54
60
  <DuxtSections />
55
61
 
62
+ <DuxtAnnouncements placement="below-header" />
63
+
56
64
  <div class="mx-auto flex w-full max-w-[90rem] flex-1 gap-8 px-4 lg:px-8">
57
65
  <!-- A section with one page has nothing to navigate, exactly as in the
58
66
  docs layout: the column would list the page the reader is on. That is
59
67
  the whole overview of a changelog with a single release. -->
60
68
  <div v-if="items.length > 1" class="hidden w-56 shrink-0 lg:block">
61
69
  <div
62
- class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pr-2"
70
+ class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pe-2"
63
71
  >
64
72
  <DuxtNavigation
65
73
  :items="items"
@@ -11,7 +11,16 @@
11
11
  <DuxtProgress />
12
12
  <DuxtShortcuts />
13
13
 
14
+ <!-- The site the layer is rendering may have something to say. Both strips
15
+ draw nothing at all until `duxt.announcements` holds one, space
16
+ included; the third placement, `above-content`, belongs to the page
17
+ rather than to the shell. -->
18
+ <DuxtAnnouncements placement="above-header" />
19
+
14
20
  <DuxtHeader />
21
+
22
+ <DuxtAnnouncements placement="below-header" />
23
+
15
24
  <main id="duxt-main" tabindex="-1" class="flex-1 outline-none">
16
25
  <slot />
17
26
  </main>
@@ -21,12 +21,20 @@ const { headerOffset } = useDuxtSectionRow();
21
21
  <DuxtProgress />
22
22
  <DuxtShortcuts />
23
23
 
24
+ <!-- The site the layer is rendering may have something to say. Both strips
25
+ draw nothing at all until `duxt.announcements` holds one, space
26
+ included; the third placement, `above-content`, belongs to the page
27
+ rather than to the shell. -->
28
+ <DuxtAnnouncements placement="above-header" />
29
+
24
30
  <DuxtHeader />
25
31
 
26
32
  <!-- The section row sits with the docs, not in the global header: the
27
33
  landing page has no sections to show. -->
28
34
  <DuxtSections />
29
35
 
36
+ <DuxtAnnouncements placement="below-header" />
37
+
30
38
  <!-- Three columns inside one centred container, not a sidebar pinned to the
31
39
  window edge: on a wide screen the docs stay a readable block instead of
32
40
  drifting apart. -->
@@ -39,7 +47,7 @@ const { headerOffset } = useDuxtSectionRow();
39
47
  the content takes the width. -->
40
48
  <div v-if="items.length > 1" class="hidden w-56 shrink-0 lg:block">
41
49
  <div
42
- class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pr-2"
50
+ class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pe-2"
43
51
  >
44
52
  <DuxtNavigation :items="items" :label="$t('duxt.nav.docs')" />
45
53
  </div>
@@ -51,15 +51,23 @@ const { headerOffset } = useDuxtSectionRow();
51
51
  <DuxtProgress />
52
52
  <DuxtShortcuts />
53
53
 
54
+ <!-- The site the layer is rendering may have something to say. Both strips
55
+ draw nothing at all until `duxt.announcements` holds one, space
56
+ included; the third placement, `above-content`, belongs to the page
57
+ rather than to the shell. -->
58
+ <DuxtAnnouncements placement="above-header" />
59
+
54
60
  <DuxtHeader />
55
61
  <DuxtSections />
56
62
 
63
+ <DuxtAnnouncements placement="below-header" />
64
+
57
65
  <div class="mx-auto flex w-full max-w-[90rem] flex-1 gap-8 px-4 lg:px-8">
58
66
  <!-- A section with one page has nothing to navigate, exactly as in the
59
67
  docs layout: the column would list the page the reader is on. -->
60
68
  <div v-if="items.length > 1" class="hidden w-64 shrink-0 lg:block">
61
69
  <div
62
- class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pr-2"
70
+ class="sticky top-[var(--duxt-header-offset)] max-h-[calc(100vh-var(--duxt-header-offset)-1.5rem)] overflow-y-auto py-8 pe-2"
63
71
  >
64
72
  <DuxtNavigation
65
73
  :items="items"
@@ -51,7 +51,5 @@ function localeCodes(): string[] {
51
51
  | { locales?: { value?: (string | { code: string })[] } }
52
52
  | undefined;
53
53
 
54
- return (i18n?.locales?.value ?? []).map((entry) =>
55
- typeof entry === 'string' ? entry : entry.code
56
- );
54
+ return localeCodesOf(i18n?.locales?.value);
57
55
  }