@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
@@ -1,6 +1,6 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
- * An image in a page: theme-aware, and zoomable.
3
+ * An image in a page: theme-aware, responsive, and zoomable.
4
4
  *
5
5
  * TWO VARIANTS. A screenshot of a dark editor on a white page — or the reverse
6
6
  * — is the most common ugly thing in documentation, and CSS cannot fix it
@@ -15,25 +15,461 @@
15
15
  * `<picture>` with `prefers-color-scheme` would ignore the site's own toggle,
16
16
  * which is a class on `<html>` and not a media query.
17
17
  *
18
- * ZOOM. A screenshot scaled to a 48rem column is often unreadable, and the
19
- * browser's own zoom is not the answer when the image is 2400px wide. Clicking
20
- * opens it at full size; Escape and a click outside close it, both from the
21
- * Dialog primitive rather than hand-rolled.
18
+ * TWO REQUESTS, NOT ONE FILE. The page and the dialog ask for different widths
19
+ * — `prose-image.ts` holds both policies and the reasoning. Until they existed
20
+ * `NuxtImg` emitted a bare `src` with no `srcset`, so a 2400px screenshot was
21
+ * downloaded whole onto a phone to be drawn into a 48rem column, and the dialog
22
+ * showing it at full resolution was an accident of there being only one file.
23
+ * A page overrides the in-page policy with `sizes`; the dialog keeps its own,
24
+ * because a policy describing the prose column cannot describe a dialog.
25
+ *
26
+ * ZOOM, AND SOMETHING THAT SAYS SO. A screenshot scaled to a 48rem column is
27
+ * often unreadable, and the browser's own zoom is not the answer when the image
28
+ * is 2400px wide. Clicking opens it larger; Escape and a click outside close
29
+ * it, both from the Dialog primitive rather than hand-rolled.
30
+ *
31
+ * The only affordance used to be `cursor-zoom-in`, which does not exist on a
32
+ * touch device and is invisible in a screenshot of the page. The indicator is
33
+ * therefore PERSISTENT rather than shown on hover: hover is the half of the
34
+ * problem that already worked. It is decorative — `aria-hidden`, inside the one
35
+ * button, never a second control — because a screen reader is told what the
36
+ * button does by its label, and two focus stops for one image is worse than
37
+ * none.
38
+ *
39
+ * ITS SURFACE IS OPAQUE, AND THAT IS THE WHOLE REASON. The chip used to be
40
+ * `bg-background/75`, which let arbitrary image pixels through behind the
41
+ * glyph — and a contrast ratio against an unknown photograph is not a ratio at
42
+ * all. Opaque, the pair is `--muted-foreground` on `--background`, which
43
+ * `tests/contrast.test.ts` already measures; the border is what separates the
44
+ * chip itself from whatever it sits on. `check:a11y` cannot judge either
45
+ * (jsdom has no computed colour), so the guarantee has to come from the
46
+ * palette rather than from the rendered page.
47
+ *
48
+ * `zoom="false"` has to leave an image looking inert: no indicator, no ring,
49
+ * no cursor, and no dialog to tab into.
50
+ *
51
+ * THE VIEWER IS A LIGHTBOX, NOT A CARD. It used to be the default dialog — a
52
+ * bordered box with a title bar — and a picture framed by chrome reads as a
53
+ * form, not as the picture. It now takes the whole viewport over a blurred,
54
+ * darkened page: controls float in a row of their own ABOVE the image and the
55
+ * alt text sits BELOW it, so neither ever covers a pixel (`check:images`
56
+ * measures exactly that). The surface is dark in both colour modes, which is
57
+ * why its controls are white-on-translucent rather than theme tokens — a
58
+ * lightbox that turned white in light mode would glare.
59
+ *
60
+ * It ZOOMS, because a screenshot fitted to a screen is still often too small to
61
+ * read: the wheel zooms around the cursor, a pinch around the fingers, a click
62
+ * on the image to 200% at that point and back, `+`, `-` and `0` from the
63
+ * keyboard, and a zoomed image is dragged to pan. The toolbar says the level
64
+ * and resets it. Clicking the empty space around the image closes, as every
65
+ * viewer a reader already knows does. The original opens in a tab of its own —
66
+ * the one the current theme shows.
22
67
  */
23
- const props = defineProps<{
24
- src?: string;
25
- alt?: string;
26
- width?: string | number;
27
- height?: string | number;
28
- /** A second file for dark mode. */
29
- dark?: string;
30
- /** `false` turns the zoom off for a decorative image. */
31
- zoom?: boolean | string;
32
- }>();
68
+ const props = withDefaults(
69
+ defineProps<{
70
+ src?: string;
71
+ alt?: string;
72
+ width?: string | number;
73
+ height?: string | number;
74
+ /** A second file for dark mode. */
75
+ dark?: string;
76
+ /** `false` turns the zoom off for a decorative image. */
77
+ zoom?: boolean | string;
78
+ /**
79
+ * The in-page policy, in `@nuxt/image`'s own `sizes` syntax — `sm:50vw
80
+ * md:400px`. For the image that is NOT as wide as the prose column: a logo,
81
+ * a badge, a diagram set in a margin.
82
+ */
83
+ sizes?: string;
84
+ /**
85
+ * A CSS colour behind the image — for a transparent vector or PNG whose
86
+ * ink disappears on the site's own background. Any colour value works,
87
+ * `var(--card)` included.
88
+ */
89
+ background?: string;
90
+ /** The same under the dark theme; falls back to `background`. */
91
+ darkBackground?: string;
92
+ }>(),
93
+ {
94
+ /**
95
+ * ZOOM IS ON UNLESS A PAGE TURNS IT OFF, AND SAYING SO IS NOT OPTIONAL.
96
+ *
97
+ * Vue casts an ABSENT prop whose declared type includes `Boolean` to
98
+ * `false` rather than to `undefined`. `zoom?: boolean | string` compiles to
99
+ * `type: [Boolean, String]`, so every image that did not write `zoom="true"`
100
+ * arrived here with `zoom === false` — and the zoom, the indicator, the
101
+ * focus ring and the dialog were all switched off on every image in the
102
+ * site. It survived from the day the component was written because nothing
103
+ * in the repository rendered one: every `![…]` in the reference sits inside
104
+ * a code block. `www/demo/docs/3.images.md` and `check:images` are the two
105
+ * halves of making sure it cannot again.
106
+ *
107
+ * A default is what suppresses the cast — `isAbsent && !hasDefault` is the
108
+ * condition on it — so this line is load-bearing, not documentation.
109
+ */
110
+ zoom: true
111
+ }
112
+ );
113
+
114
+ const { t } = useI18n();
33
115
 
34
116
  const open = ref(false);
35
117
 
36
- const zoomable = computed(() => props.zoom !== false && props.zoom !== 'false');
118
+ const zoomable = computed(
119
+ () => Boolean(props.src) && props.zoom !== false && props.zoom !== 'false'
120
+ );
121
+
122
+ const pageSizes = computed(() => proseImageSizes(props.sizes));
123
+
124
+ /**
125
+ * The light file and its dark twin, each carrying whether a provider may touch
126
+ * it.
127
+ *
128
+ * A list rather than four near-identical blocks in the template, and it is the
129
+ * same list in the page and in the dialog — the two differ only in their sizing
130
+ * policy and their classes, which is the point.
131
+ */
132
+ type Variant = { src: string; theme: string; plain: boolean };
133
+
134
+ const variants = computed<Variant[]>(() => {
135
+ const list: Variant[] = [];
136
+
137
+ if (props.src) {
138
+ list.push({
139
+ src: props.src,
140
+ // Hidden under the dark theme only when there is something to swap to.
141
+ theme: props.dark ? 'block dark:hidden' : '',
142
+ plain: proseImagePassThrough(props.src)
143
+ });
144
+ }
145
+
146
+ if (props.dark) {
147
+ list.push({
148
+ src: props.dark,
149
+ theme: 'hidden dark:block',
150
+ plain: proseImagePassThrough(props.dark)
151
+ });
152
+ }
153
+
154
+ return list;
155
+ });
156
+
157
+ /**
158
+ * What a screen reader hears on the button.
159
+ *
160
+ * Two keys rather than one with an empty interpolation: a decorative image
161
+ * carries no alt text, and "Enlarge image: " read aloud is worse than
162
+ * "Enlarge image".
163
+ */
164
+ const label = computed(() =>
165
+ props.alt
166
+ ? t('duxt.page.image.zoomNamed', { alt: props.alt })
167
+ : t('duxt.page.image.zoom')
168
+ );
169
+
170
+ /**
171
+ * COLOURS, NOT CLASSES. A page cannot pass Tailwind classes to an image: the
172
+ * layer's stylesheet is compiled from `app/` alone, never from Markdown, so a
173
+ * `dark:bg-…` written in a page would be a class no CSS exists for — silently.
174
+ * The values travel as custom properties instead, and the one class that reads
175
+ * them lives here, where Tailwind sees it.
176
+ *
177
+ * Applied to the image in the page AND in the viewer: a transparent diagram is
178
+ * just as unreadable on the viewer's dark surface.
179
+ */
180
+ const backdrop = computed(() => {
181
+ if (!props.background && !props.darkBackground) return undefined;
182
+
183
+ return {
184
+ class: 'bg-(--duxt-img-bg) dark:bg-(--duxt-img-bg-dark)',
185
+ style: {
186
+ '--duxt-img-bg': props.background ?? 'transparent',
187
+ '--duxt-img-bg-dark': props.darkBackground ?? props.background
188
+ }
189
+ };
190
+ });
191
+
192
+ /** The dialog is named by the image, and by what it is only when the image is unnamed. */
193
+ const title = computed(() => props.alt || t('duxt.page.image.zoom'));
194
+
195
+ const colorMode = useColorMode();
196
+
197
+ /** The file the reader is looking at — the dark twin only under the dark theme. */
198
+ const original = computed(() =>
199
+ props.dark && colorMode.value === 'dark' ? props.dark : props.src
200
+ );
201
+
202
+ /**
203
+ * ZOOM IS ONE SCALE AND ONE OFFSET, applied as a transform on the picture.
204
+ *
205
+ * A transform rather than a larger box in a scroller, for two reasons: it zooms
206
+ * around a POINT — the cursor, the fingers — which a scroller can only fake by
207
+ * chasing its own scroll position, and it never changes layout, so the image
208
+ * keeps the fitted box `check:images` measures. The offset is kept inside the
209
+ * image's reach, so a picture can be panned to its edge but never off-screen.
210
+ *
211
+ * Reset on every open: a viewer that remembered the last image's zoom would
212
+ * open the next one already cropped.
213
+ */
214
+ /**
215
+ * The levels `+` and `-` stop at — round numbers, because the toolbar prints
216
+ * the level and 338% reads as a bug. The wheel and a pinch stay continuous,
217
+ * which is what a hand expects; the next button press snaps back onto a step.
218
+ */
219
+ const ZOOM_STEPS = [1, 1.5, 2, 3, 4, 6, 8];
220
+ const MAX_SCALE = ZOOM_STEPS.at(-1)!;
221
+ const ZOOM_CLICK = 2;
222
+
223
+ const scale = ref(1);
224
+ const offset = ref({ x: 0, y: 0 });
225
+ const dragging = ref(false);
226
+ const zoomed = computed(() => scale.value > 1);
227
+ const stage = useTemplateRef<HTMLElement>('stage');
228
+
229
+ watch(open, resetZoom);
230
+
231
+ function resetZoom(): void {
232
+ scale.value = 1;
233
+ offset.value = { x: 0, y: 0 };
234
+ }
235
+
236
+ /**
237
+ * Keep the picture's edge from leaving its side of the stage.
238
+ *
239
+ * `offsetWidth` is the laid-out box, untouched by the transform — so this is
240
+ * the scale-1 size whatever the current zoom. The hidden theme twin measures 0
241
+ * and drops out of the `max`.
242
+ */
243
+ function clamp(x: number, y: number, next: number) {
244
+ const el = stage.value;
245
+ if (!el) return { x, y };
246
+
247
+ const images = [...el.querySelectorAll<HTMLElement>('[data-viewer-image]')];
248
+ const width = Math.max(0, ...images.map((image) => image.offsetWidth));
249
+ const height = Math.max(0, ...images.map((image) => image.offsetHeight));
250
+ const maxX = Math.max(0, (width * next - el.clientWidth) / 2);
251
+ const maxY = Math.max(0, (height * next - el.clientHeight) / 2);
252
+
253
+ return {
254
+ x: Math.min(maxX, Math.max(-maxX, x)),
255
+ y: Math.min(maxY, Math.max(-maxY, y))
256
+ };
257
+ }
258
+
259
+ /** A viewport point, relative to the centre of the stage the image is centred in. */
260
+ function fromClient(clientX: number, clientY: number) {
261
+ const rect = stage.value?.getBoundingClientRect();
262
+ if (!rect) return { x: 0, y: 0 };
263
+
264
+ return {
265
+ x: clientX - rect.x - rect.width / 2,
266
+ y: clientY - rect.y - rect.height / 2
267
+ };
268
+ }
269
+
270
+ /**
271
+ * The next step above the current level, or below it — strictly, so a level
272
+ * the wheel left between two steps moves to the nearer one in that direction
273
+ * rather than skipping it.
274
+ */
275
+ function stepZoom(direction: 1 | -1): void {
276
+ const current = scale.value;
277
+ const next =
278
+ direction === 1
279
+ ? ZOOM_STEPS.find((step) => step > current + 0.001)
280
+ : ZOOM_STEPS.findLast((step) => step < current - 0.001);
281
+
282
+ zoomTo(next ?? (direction === 1 ? MAX_SCALE : 1));
283
+ }
284
+
285
+ /** Zoom to `next`, keeping the picture's pixel under `point` where it is. */
286
+ function zoomTo(next: number, point = { x: 0, y: 0 }): void {
287
+ const target = Math.min(MAX_SCALE, Math.max(1, next));
288
+ const ratio = target / scale.value;
289
+
290
+ scale.value = target;
291
+ offset.value =
292
+ target === 1
293
+ ? { x: 0, y: 0 }
294
+ : clamp(
295
+ point.x - (point.x - offset.value.x) * ratio,
296
+ point.y - (point.y - offset.value.y) * ratio,
297
+ target
298
+ );
299
+ }
300
+
301
+ function onWheel(event: WheelEvent): void {
302
+ // Line-mode wheels report in lines, not pixels.
303
+ const delta = event.deltaY * (event.deltaMode === 1 ? 16 : 1);
304
+ zoomTo(
305
+ scale.value * Math.exp(-delta * 0.0015),
306
+ fromClient(event.clientX, event.clientY)
307
+ );
308
+ }
309
+
310
+ function onKeydown(event: KeyboardEvent): void {
311
+ if (event.key === '+' || event.key === '=') stepZoom(1);
312
+ else if (event.key === '-') stepZoom(-1);
313
+ else if (event.key === '0') resetZoom();
314
+ else return;
315
+
316
+ event.preventDefault();
317
+ }
318
+
319
+ /**
320
+ * Drag to pan, two fingers to pinch.
321
+ *
322
+ * `moved` is what tells a drag from a click: a pan that ends over the empty
323
+ * stage must not close the viewer, and one that ends over the image must not
324
+ * zoom it. The pointer is captured only once it has MOVED — captured from
325
+ * `pointerdown`, every click would be retargeted to the stage and a click on
326
+ * the image could no longer be told from a click beside it.
327
+ */
328
+ const pointers = new Map<number, { x: number; y: number }>();
329
+ let pinch: { distance: number; scale: number } | undefined;
330
+ let travel = 0;
331
+ let moved = false;
332
+
333
+ function onPointerDown(event: PointerEvent): void {
334
+ if (event.pointerType === 'mouse' && event.button !== 0) return;
335
+
336
+ pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
337
+ travel = 0;
338
+ moved = false;
339
+
340
+ if (pointers.size === 2) {
341
+ const [a, b] = [...pointers.values()];
342
+ pinch = {
343
+ distance: Math.hypot(a!.x - b!.x, a!.y - b!.y),
344
+ scale: scale.value
345
+ };
346
+ }
347
+ }
348
+
349
+ function onPointerMove(event: PointerEvent): void {
350
+ const previous = pointers.get(event.pointerId);
351
+ if (!previous) return;
352
+
353
+ const now = { x: event.clientX, y: event.clientY };
354
+ pointers.set(event.pointerId, now);
355
+
356
+ if (pinch && pointers.size === 2) {
357
+ const [a, b] = [...pointers.values()];
358
+ moved = true;
359
+ zoomTo(
360
+ (pinch.scale * Math.hypot(a!.x - b!.x, a!.y - b!.y)) / pinch.distance,
361
+ fromClient((a!.x + b!.x) / 2, (a!.y + b!.y) / 2)
362
+ );
363
+ return;
364
+ }
365
+
366
+ if (!zoomed.value) return;
367
+
368
+ const dx = now.x - previous.x;
369
+ const dy = now.y - previous.y;
370
+ travel += Math.abs(dx) + Math.abs(dy);
371
+
372
+ if (!moved && travel > 4) {
373
+ moved = true;
374
+ dragging.value = true;
375
+ stage.value?.setPointerCapture(event.pointerId);
376
+ }
377
+
378
+ if (moved) {
379
+ offset.value = clamp(offset.value.x + dx, offset.value.y + dy, scale.value);
380
+ }
381
+ }
382
+
383
+ function onPointerUp(event: PointerEvent): void {
384
+ pointers.delete(event.pointerId);
385
+ if (pointers.size < 2) pinch = undefined;
386
+ if (!pointers.size) dragging.value = false;
387
+ }
388
+
389
+ function onImageClick(event: MouseEvent): void {
390
+ if (moved) return;
391
+
392
+ if (zoomed.value) resetZoom();
393
+ else zoomTo(ZOOM_CLICK, fromClient(event.clientX, event.clientY));
394
+ }
395
+
396
+ function onStageClick(): void {
397
+ if (!moved) open.value = false;
398
+ }
399
+
400
+ const image = useImage();
401
+
402
+ /**
403
+ * The picture's sources: the dialog's `srcset` while it fits, the ORIGINAL
404
+ * once it is zoomed.
405
+ *
406
+ * A `srcset` candidate is picked for the fitted width, so at 200% it is a
407
+ * blurred enlargement of a screen-sized file. The original is fetched only
408
+ * when a reader zooms. It is the same `<img>` either way, on purpose: changing
409
+ * the sources of one element keeps the current picture on screen until the
410
+ * new file has loaded, where swapping elements would flash an empty box.
411
+ */
412
+ function viewerSources(variant: Variant) {
413
+ if (variant.plain || zoomed.value) return { src: variant.src };
414
+
415
+ const sources = image.getSizes(variant.src, {
416
+ sizes: PROSE_IMAGE_ZOOM_SIZES
417
+ });
418
+
419
+ return { src: sources.src, srcset: sources.srcset, sizes: sources.sizes };
420
+ }
421
+
422
+ /** One look for the viewer's round controls, which sit on the dark surface in both themes. */
423
+ const viewerButton =
424
+ 'inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full bg-white/10 text-white ring-1 ring-white/15 transition-colors hover:bg-white/20 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none';
425
+
426
+ /** The zoom group's buttons, drawn inside one shared pill rather than each in its own. */
427
+ const zoomButton =
428
+ 'inline-flex h-8 shrink-0 cursor-pointer items-center justify-center rounded-full text-white transition-colors hover:bg-white/15 focus-visible:ring-2 focus-visible:ring-white focus-visible:outline-none disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent';
429
+
430
+ const viewerImage = computed(() => [
431
+ 'm-auto h-auto max-h-full w-auto max-w-[min(100%,80rem)] rounded-lg object-contain shadow-2xl ring-1 ring-white/10 select-none',
432
+ dragging.value
433
+ ? 'transition-none'
434
+ : 'transition-transform duration-150 ease-out',
435
+ zoomed.value
436
+ ? dragging.value
437
+ ? 'cursor-grabbing'
438
+ : 'cursor-grab'
439
+ : 'cursor-zoom-in'
440
+ ]);
441
+
442
+ /**
443
+ * THE DIALOG'S FILES HAVE TO EXIST BEFORE ANYONE OPENS IT.
444
+ *
445
+ * A static build learns which image variants to generate from the ones it
446
+ * rendered: `NuxtImg` appends every URL it emits to an `x-nitro-prerender`
447
+ * header, and Nitro's crawler prerenders what that header names. The dialog is
448
+ * not rendered until it opens, so on a generated site its variants were never
449
+ * requested, never written, and the first click found a 404.
450
+ *
451
+ * Asking for the sizes here registers them without rendering anything —
452
+ * `getSizes` runs each URL through the provider, and the provider is what
453
+ * appends the header. The browser downloads none of it: no markup carries these
454
+ * URLs until the dialog mounts.
455
+ *
456
+ * `prose-image.ts` decides WHICH images are on the list — an image with no
457
+ * dialog to open must not have a set of dialog-sized files written for it.
458
+ *
459
+ * Prerender only. On a server that transforms on demand there is nothing to
460
+ * generate ahead of time, and on the client this would be a wasted pass over
461
+ * every image on the page.
462
+ */
463
+ if (import.meta.server && import.meta.prerender) {
464
+ const image = useImage();
465
+
466
+ for (const src of proseImageZoomPrerenderSources(
467
+ zoomable.value,
468
+ variants.value.map((variant) => variant.src)
469
+ )) {
470
+ image.getSizes(src, { sizes: PROSE_IMAGE_ZOOM_SIZES });
471
+ }
472
+ }
37
473
  </script>
38
474
 
39
475
  <template>
@@ -41,46 +477,157 @@ const zoomable = computed(() => props.zoom !== false && props.zoom !== 'false');
41
477
  <component
42
478
  :is="zoomable ? 'button' : 'span'"
43
479
  :type="zoomable ? 'button' : undefined"
44
- class="block w-full"
45
- :class="zoomable ? 'cursor-zoom-in' : undefined"
46
- :aria-label="zoomable ? alt : undefined"
480
+ class="relative block w-fit max-w-full"
481
+ :class="
482
+ zoomable
483
+ ? 'group ring-offset-background cursor-zoom-in rounded-lg focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:outline-none'
484
+ : undefined
485
+ "
486
+ :aria-label="zoomable ? label : undefined"
47
487
  @click="zoomable && (open = true)"
48
488
  >
49
- <NuxtImg
50
- :src="src"
51
- :alt="alt"
52
- :width="width"
53
- :height="height"
54
- loading="lazy"
55
- class="rounded-lg border"
56
- :class="dark ? 'block dark:hidden' : undefined"
57
- />
58
- <NuxtImg
59
- v-if="dark"
60
- :src="dark"
61
- :alt="alt"
62
- :width="width"
63
- :height="height"
64
- loading="lazy"
65
- class="hidden rounded-lg border dark:block"
66
- />
67
- </component>
68
-
69
- <UiDialog v-if="zoomable" v-model:open="open">
70
- <UiDialogContent class="max-w-[min(96vw,80rem)] p-2">
71
- <UiDialogTitle class="sr-only">{{ alt }}</UiDialogTitle>
72
- <NuxtImg
73
- :src="src"
489
+ <template v-for="variant in variants" :key="variant.src">
490
+ <!-- A vector has no pixels to resample and an animation does not
491
+ survive one: both are served exactly as committed. -->
492
+ <img
493
+ v-if="variant.plain"
494
+ :src="variant.src"
74
495
  :alt="alt"
75
- class="max-h-[85vh] w-full rounded-md object-contain"
76
- :class="dark ? 'block dark:hidden' : undefined"
496
+ :width="width"
497
+ :height="height"
498
+ loading="lazy"
499
+ class="h-auto max-w-full rounded-lg border"
500
+ :class="[variant.theme, backdrop?.class]"
501
+ :style="backdrop?.style"
77
502
  />
78
503
  <NuxtImg
79
- v-if="dark"
80
- :src="dark"
504
+ v-else
505
+ :src="variant.src"
81
506
  :alt="alt"
82
- class="hidden max-h-[85vh] w-full rounded-md object-contain dark:block"
507
+ :width="width"
508
+ :height="height"
509
+ :sizes="pageSizes"
510
+ loading="lazy"
511
+ class="h-auto max-w-full rounded-lg border"
512
+ :class="[variant.theme, backdrop?.class]"
513
+ :style="backdrop?.style"
83
514
  />
515
+ </template>
516
+
517
+ <span
518
+ v-if="zoomable"
519
+ aria-hidden="true"
520
+ class="pointer-events-none absolute top-2 end-2 flex size-7 items-center justify-center rounded-md border bg-background text-muted-foreground shadow-sm transition-colors group-hover:text-foreground"
521
+ >
522
+ <Icon name="lucide:zoom-in" class="size-4" />
523
+ </span>
524
+ </component>
525
+
526
+ <UiDialog v-if="zoomable" v-model:open="open">
527
+ <!-- The whole viewport, transparent over the overlay: the blur is what
528
+ separates the picture from the page, not a border. -->
529
+ <UiDialogContent
530
+ :show-close-button="false"
531
+ :aria-describedby="undefined"
532
+ class="inset-0 grid h-dvh w-screen max-w-none translate-none grid-rows-[auto_minmax(0,1fr)_auto] gap-0 rounded-none border-0 bg-neutral-950/60 p-0 shadow-none backdrop-blur-md sm:max-w-none"
533
+ @click.self="open = false"
534
+ @keydown="onKeydown"
535
+ >
536
+ <div
537
+ class="flex items-center justify-end gap-2 p-3 sm:p-4"
538
+ @click.self="open = false"
539
+ >
540
+ <div
541
+ class="flex items-center gap-0.5 rounded-full bg-white/10 p-0.5 ring-1 ring-white/15"
542
+ >
543
+ <button
544
+ type="button"
545
+ :aria-label="t('duxt.page.image.zoomOut')"
546
+ :disabled="!zoomed"
547
+ :class="[zoomButton, 'w-8']"
548
+ @click="stepZoom(-1)"
549
+ >
550
+ <Icon name="lucide:minus" class="size-4" />
551
+ </button>
552
+ <button
553
+ type="button"
554
+ :aria-label="t('duxt.page.image.resetZoom')"
555
+ :disabled="!zoomed"
556
+ :class="[
557
+ zoomButton,
558
+ 'min-w-14 px-2 text-xs font-medium tabular-nums'
559
+ ]"
560
+ @click="resetZoom"
561
+ >
562
+ {{ Math.round(scale * 100) }}%
563
+ </button>
564
+ <button
565
+ type="button"
566
+ :aria-label="t('duxt.page.image.zoomIn')"
567
+ :disabled="scale >= MAX_SCALE"
568
+ :class="[zoomButton, 'w-8']"
569
+ @click="stepZoom(1)"
570
+ >
571
+ <Icon name="lucide:plus" class="size-4" />
572
+ </button>
573
+ </div>
574
+ <a
575
+ :href="original"
576
+ target="_blank"
577
+ rel="noopener"
578
+ :aria-label="t('duxt.page.image.original')"
579
+ :class="viewerButton"
580
+ >
581
+ <Icon name="lucide:external-link" class="size-4" />
582
+ </a>
583
+ <UiDialogClose
584
+ :aria-label="t('duxt.page.image.close')"
585
+ :class="viewerButton"
586
+ >
587
+ <Icon name="lucide:x" class="size-4" />
588
+ </UiDialogClose>
589
+ </div>
590
+
591
+ <!-- `m-auto` centres the picture; `touch-none` hands pinches and
592
+ drags to the handlers instead of scrolling or zooming the page
593
+ underneath. -->
594
+ <div
595
+ ref="stage"
596
+ class="flex min-h-0 touch-none overflow-hidden px-3 sm:px-8"
597
+ @click.self="onStageClick"
598
+ @wheel.prevent="onWheel"
599
+ @pointerdown="onPointerDown"
600
+ @pointermove="onPointerMove"
601
+ @pointerup="onPointerUp"
602
+ @pointercancel="onPointerUp"
603
+ >
604
+ <img
605
+ v-for="variant in variants"
606
+ :key="variant.src"
607
+ v-bind="viewerSources(variant)"
608
+ :alt="alt"
609
+ data-viewer-image
610
+ draggable="false"
611
+ :class="[viewerImage, variant.theme, backdrop?.class]"
612
+ :style="{
613
+ ...backdrop?.style,
614
+ transform: `translate(${offset.x}px, ${offset.y}px) scale(${scale})`
615
+ }"
616
+ @click="onImageClick"
617
+ />
618
+ </div>
619
+
620
+ <div class="px-4 py-3 sm:py-4" @click.self="open = false">
621
+ <UiDialogTitle
622
+ :class="
623
+ alt
624
+ ? 'mx-auto max-w-prose text-center text-sm text-balance text-white/80'
625
+ : 'sr-only'
626
+ "
627
+ >
628
+ {{ title }}
629
+ </UiDialogTitle>
630
+ </div>
84
631
  </UiDialogContent>
85
632
  </UiDialog>
86
633
  </span>
@@ -15,7 +15,7 @@ defineProps<{ version?: string }>();
15
15
  </script>
16
16
 
17
17
  <template>
18
- <UiBadge variant="secondary" class="ml-2 align-middle font-mono text-[10px]">
18
+ <UiBadge variant="secondary" class="ms-2 align-middle font-mono text-[10px]">
19
19
  {{ $t('duxt.page.since', { version }) }}
20
20
  </UiBadge>
21
21
  </template>