@kirchdev/duxt 0.3.4 → 0.3.5-dev-main.43-3310f4b

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/README.md +24 -9
  2. package/app/app.vue +17 -11
  3. package/app/assets/css/duxt.css +47 -6
  4. package/app/components/DuxtAnnouncements.vue +117 -0
  5. package/app/components/DuxtBrunoEntries.vue +62 -0
  6. package/app/components/DuxtCodeBlock.vue +18 -34
  7. package/app/components/DuxtCodeToolbar.vue +14 -0
  8. package/app/components/DuxtCopyButton.vue +33 -0
  9. package/app/components/DuxtCopyPage.vue +32 -30
  10. package/app/components/DuxtFooter.vue +39 -8
  11. package/app/components/DuxtHeader.vue +107 -48
  12. package/app/components/DuxtLandingProse.vue +1 -1
  13. package/app/components/DuxtLinkLabel.vue +36 -0
  14. package/app/components/DuxtLiveWindow.vue +8 -8
  15. package/app/components/DuxtLocale.vue +10 -1
  16. package/app/components/DuxtNavigation.vue +2 -2
  17. package/app/components/DuxtOpenApiClient.vue +285 -90
  18. package/app/components/DuxtOpenApiMedia.vue +30 -18
  19. package/app/components/DuxtOpenApiSchema.vue +10 -7
  20. package/app/components/DuxtPageFeedback.vue +7 -0
  21. package/app/components/DuxtPageInfo.vue +15 -12
  22. package/app/components/DuxtPageNav.vue +41 -27
  23. package/app/components/DuxtPreviewSkeleton.vue +4 -4
  24. package/app/components/DuxtProgress.vue +2 -14
  25. package/app/components/DuxtSearch.vue +230 -90
  26. package/app/components/DuxtSearchInput.vue +70 -0
  27. package/app/components/DuxtSections.vue +11 -1
  28. package/app/components/DuxtSegmented.vue +64 -0
  29. package/app/components/DuxtShortcuts.vue +16 -11
  30. package/app/components/DuxtShortcutsTrigger.vue +49 -0
  31. package/app/components/DuxtSkipLink.vue +1 -1
  32. package/app/components/DuxtToc.vue +16 -10
  33. package/app/components/DuxtTranslationBanner.vue +1 -1
  34. package/app/components/DuxtVersion.vue +53 -12
  35. package/app/components/DuxtVersionBanner.vue +5 -5
  36. package/app/components/content/AccordionItem.vue +2 -2
  37. package/app/components/content/BrunoOverview.vue +260 -0
  38. package/app/components/content/BrunoRequest.vue +141 -0
  39. package/app/components/content/BrunoRequests.vue +104 -0
  40. package/app/components/content/Callout.vue +5 -5
  41. package/app/components/content/ChangelogReleases.vue +161 -40
  42. package/app/components/content/CodeGroup.vue +41 -37
  43. package/app/components/content/Field.vue +1 -1
  44. package/app/components/content/FileTree.vue +1 -1
  45. package/app/components/content/OpenApiOperation.vue +4 -4
  46. package/app/components/content/OpenApiOperations.vue +1 -1
  47. package/app/components/content/OpenApiOverview.vue +1 -1
  48. package/app/components/content/PackageManagers.vue +42 -58
  49. package/app/components/content/PageCards.vue +7 -4
  50. package/app/components/content/Preview.vue +15 -39
  51. package/app/components/content/ProseImg.vue +596 -49
  52. package/app/components/content/Since.vue +1 -1
  53. package/app/components/content/Steps.vue +6 -4
  54. package/app/components/content/TabGroup.vue +13 -12
  55. package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
  56. package/app/components/ui/command/CommandShortcut.vue +1 -1
  57. package/app/components/ui/dialog/DialogContent.vue +2 -2
  58. package/app/components/ui/dialog/DialogHeader.vue +1 -1
  59. package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
  60. package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
  61. package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
  62. package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
  63. package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
  64. package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
  65. package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
  66. package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
  67. package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
  68. package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
  69. package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
  70. package/app/components/ui/select/SelectItem.vue +2 -2
  71. package/app/components/ui/sheet/SheetContent.vue +29 -3
  72. package/app/components/ui/sidebar/Sidebar.vue +28 -4
  73. package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
  74. package/app/components/ui/sidebar/SidebarInset.vue +1 -1
  75. package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
  76. package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
  77. package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
  78. package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
  79. package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
  80. package/app/components/ui/sidebar/index.ts +10 -2
  81. package/app/components/ui/sidebar/utils.ts +0 -1
  82. package/app/components/ui/sonner/Sonner.vue +18 -0
  83. package/app/components/ui/tabs/TabsList.vue +13 -3
  84. package/app/components/ui/tabs/TabsTrigger.vue +24 -3
  85. package/app/components/ui/tooltip/TooltipContent.vue +2 -2
  86. package/app/composables/useActiveHeading.ts +3 -25
  87. package/app/composables/useDuxtAnalytics.ts +50 -0
  88. package/app/composables/useDuxtAnimatedHeight.ts +8 -38
  89. package/app/composables/useDuxtAnnouncements.ts +153 -0
  90. package/app/composables/useDuxtCopy.ts +51 -0
  91. package/app/composables/useDuxtDirection.ts +23 -0
  92. package/app/composables/useDuxtNavigation.ts +28 -8
  93. package/app/composables/useDuxtPath.ts +3 -5
  94. package/app/composables/useDuxtSearch.ts +87 -35
  95. package/app/composables/useDuxtSection.ts +15 -1
  96. package/app/composables/useDuxtSectionRow.ts +6 -5
  97. package/app/composables/useDuxtShortcuts.ts +319 -20
  98. package/app/composables/useDuxtVersion.ts +57 -6
  99. package/app/composables/useDuxtViewportMeasure.ts +37 -0
  100. package/app/composables/useFuzzySearch.ts +9 -4
  101. package/app/composables/useRecentPages.ts +14 -17
  102. package/app/error.vue +112 -74
  103. package/app/layouts/changelog.vue +9 -1
  104. package/app/layouts/default.vue +9 -0
  105. package/app/layouts/docs.vue +9 -1
  106. package/app/layouts/reference.vue +9 -1
  107. package/app/middleware/duxt-section-layout.global.ts +1 -3
  108. package/app/pages/[...slug].vue +146 -35
  109. package/app/pages/index.vue +15 -23
  110. package/app/types/duxt.d.ts +312 -4
  111. package/app/utils/analytics.ts +91 -0
  112. package/app/utils/announcements.ts +205 -0
  113. package/app/utils/changelog.ts +0 -28
  114. package/app/utils/code-highlight.ts +3 -1
  115. package/app/utils/contributor-avatar.ts +27 -0
  116. package/app/utils/direction.ts +43 -0
  117. package/app/utils/duxt-config.ts +43 -0
  118. package/app/utils/duxt-text.ts +10 -0
  119. package/app/utils/format-date.ts +28 -0
  120. package/app/utils/generated-sections.ts +68 -12
  121. package/app/utils/generated-toc.ts +14 -7
  122. package/app/utils/locale-path.ts +43 -0
  123. package/app/utils/navigation-tree.ts +51 -1
  124. package/app/utils/nearest-page.ts +8 -0
  125. package/app/utils/openapi.ts +16 -11
  126. package/app/utils/page-controls.ts +215 -0
  127. package/app/utils/pill.ts +61 -0
  128. package/app/utils/prose-image.ts +128 -0
  129. package/app/utils/search-context.ts +110 -0
  130. package/app/utils/search-display.ts +248 -0
  131. package/app/utils/section-scope.ts +65 -4
  132. package/app/utils/stored-json.ts +31 -0
  133. package/app/utils/version-choices.ts +6 -13
  134. package/app/utils/version-paths.ts +21 -0
  135. package/bin/duxt-cache-key.mjs +61 -0
  136. package/bin/duxt-og-cache.mjs +58 -0
  137. package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
  138. package/bruno-model.ts +169 -0
  139. package/bruno-parse.ts +656 -0
  140. package/bruno-zip.ts +195 -0
  141. package/cli.ts +240 -0
  142. package/duxt-app-config.ts +18 -1
  143. package/frontmatter.ts +49 -0
  144. package/git-contributors.ts +322 -0
  145. package/i18n/locales/de/duxt/announcement.json +7 -0
  146. package/i18n/locales/de/duxt/bruno.json +25 -0
  147. package/i18n/locales/de/duxt/changelog.json +1 -1
  148. package/i18n/locales/de/duxt/code.json +2 -1
  149. package/i18n/locales/de/duxt/page.json +9 -0
  150. package/i18n/locales/de/duxt/shortcuts.json +1 -0
  151. package/i18n/locales/de/duxt/theme.json +2 -1
  152. package/i18n/locales/en/duxt/announcement.json +7 -0
  153. package/i18n/locales/en/duxt/bruno.json +25 -0
  154. package/i18n/locales/en/duxt/changelog.json +1 -1
  155. package/i18n/locales/en/duxt/code.json +2 -1
  156. package/i18n/locales/en/duxt/page.json +9 -0
  157. package/i18n/locales/en/duxt/shortcuts.json +1 -0
  158. package/i18n/locales/en/duxt/theme.json +2 -1
  159. package/i18n/locales/es/duxt/announcement.json +7 -0
  160. package/i18n/locales/es/duxt/bruno.json +25 -0
  161. package/i18n/locales/es/duxt/changelog.json +1 -1
  162. package/i18n/locales/es/duxt/code.json +2 -1
  163. package/i18n/locales/es/duxt/page.json +9 -0
  164. package/i18n/locales/es/duxt/shortcuts.json +1 -0
  165. package/i18n/locales/es/duxt/theme.json +2 -1
  166. package/i18n/locales/fr/duxt/announcement.json +7 -0
  167. package/i18n/locales/fr/duxt/bruno.json +25 -0
  168. package/i18n/locales/fr/duxt/changelog.json +1 -1
  169. package/i18n/locales/fr/duxt/code.json +2 -1
  170. package/i18n/locales/fr/duxt/page.json +9 -0
  171. package/i18n/locales/fr/duxt/shortcuts.json +1 -0
  172. package/i18n/locales/fr/duxt/theme.json +2 -1
  173. package/i18n/locales/pt/duxt/announcement.json +7 -0
  174. package/i18n/locales/pt/duxt/bruno.json +25 -0
  175. package/i18n/locales/pt/duxt/changelog.json +1 -1
  176. package/i18n/locales/pt/duxt/code.json +2 -1
  177. package/i18n/locales/pt/duxt/page.json +9 -0
  178. package/i18n/locales/pt/duxt/shortcuts.json +1 -0
  179. package/i18n/locales/pt/duxt/theme.json +2 -1
  180. package/modules/bruno.ts +118 -0
  181. package/modules/config.ts +26 -0
  182. package/modules/devtools.ts +2 -3
  183. package/modules/git-meta.ts +16 -47
  184. package/modules/search-records.ts +132 -0
  185. package/nuxt.config.ts +81 -7
  186. package/og-image-cache.ts +742 -0
  187. package/package.json +41 -9
  188. package/public/devtools/checks.html +2 -2
  189. package/public/devtools/config.html +59 -4
  190. package/public/devtools/pages.html +8 -1
  191. package/public/devtools/paths.html +1 -1
  192. package/public/devtools/sources.html +2 -2
  193. package/public/devtools/versions.html +3 -0
  194. package/report.ts +28 -19
  195. package/search-records.ts +380 -0
  196. package/section-input.ts +142 -0
  197. package/section-markdown.ts +67 -0
  198. package/section-reports.ts +10 -4
  199. package/sections-bruno.ts +571 -0
  200. package/sections-changelog.ts +76 -43
  201. package/sections-openapi.ts +12 -83
  202. package/sections-resolve.ts +174 -8
  203. package/sections.ts +33 -10
  204. package/server/devtools/shell.ts +1 -1
  205. package/server/mcp/tools/list-pages.ts +77 -36
  206. package/server/mcp/tools/list-versions.ts +84 -0
  207. package/server/mcp/tools/read-page.ts +80 -26
  208. package/server/mcp/tools/search-docs.ts +87 -53
  209. package/server/middleware/raw-markdown.ts +1 -1
  210. package/server/routes/llms-full.txt.get.ts +6 -8
  211. package/server/routes/llms.txt.get.ts +1 -1
  212. package/server/utils/duxt-server-text.ts +0 -13
  213. package/server/utils/llms-pages.ts +24 -9
  214. package/server/utils/mcp-docs.ts +577 -0
  215. package/sources-cache.ts +376 -0
  216. package/sources-git.ts +186 -31
  217. package/sources-resolve.ts +59 -1
  218. package/sources.ts +122 -13
  219. package/tfplugindocs.ts +158 -0
@@ -0,0 +1,742 @@
1
+ /**
2
+ * The rendered OG images, kept between builds, and the one rule that makes
3
+ * keeping them safe.
4
+ *
5
+ * WHAT NUXT-OG-IMAGE ALREADY DOES. Its build cache keys every image by a hash
6
+ * of three things: the page's own options (its title, its description, its
7
+ * props), the SOURCE of the template component, and the module's own version.
8
+ * Change a heading and that page's image is re-rendered; change the template
9
+ * and every image is. That half needs nothing from this file — only a directory
10
+ * that outlives the runner, which is what `ogImage.buildCache` is.
11
+ *
12
+ * WHAT IT DOES NOT COVER, and why this file exists. Nothing in that key sees
13
+ * the FONTS, the renderer options (`satoriOptions`, `resvgOptions`), or the
14
+ * version of `satori` and `@resvg/resvg-js` themselves — all of which decide
15
+ * what an image looks like, and none of which is a per-image option. Change a
16
+ * font and every cached image is a picture of the old one, served with no
17
+ * warning anywhere: the page is fine, the build is green, and the image is
18
+ * wrong. So the directory carries a STAMP of those inputs and is emptied when
19
+ * it moves.
20
+ *
21
+ * TWO LEVELS, AND THEY ARE NOT THE SAME QUESTION. The stamp decides whether an
22
+ * image may be SERVED and is written by the build, which can see the site's own
23
+ * configuration. The CI key decides whether a tarball is worth DOWNLOADING and
24
+ * is written by a command, which cannot — a site's renderer options live in a
25
+ * `nuxt.config.ts` that would claim a Nuxt process if a command loaded it. So
26
+ * the key is namespaced by the renderer versions alone. Being too generous
27
+ * there costs one download of a directory the build then empties; being too
28
+ * strict would cost every build its cache. Being wrong about the STAMP would
29
+ * cost a thousand wrong images, which is why that one is the build's.
30
+ *
31
+ * WHAT IS DELIBERATELY NOT AN INPUT: the render budget. A timeout cannot change
32
+ * a pixel, and it is the lever the prerender-concurrency work has to be free to
33
+ * move without discarding everything rendered so far.
34
+ */
35
+
36
+ import { createHash } from 'node:crypto';
37
+ import {
38
+ existsSync,
39
+ mkdirSync,
40
+ readFileSync,
41
+ readdirSync,
42
+ rmSync,
43
+ statSync,
44
+ writeFileSync
45
+ } from 'node:fs';
46
+ import { dirname, join, relative, resolve } from 'node:path';
47
+
48
+ /**
49
+ * The keys of `ogImage` that decide what a rendered image LOOKS like.
50
+ *
51
+ * Everything else the module takes is about when an image is rendered or where
52
+ * it is kept — `buildCache`, `zeroRuntime`, `security.renderTimeout`, `debug`,
53
+ * `enabled`, `runtimeCacheStorage`. None of them can change a pixel, and every
54
+ * one of them is a lever someone will want to move without throwing away a
55
+ * thousand rendered images, so none of them is in here.
56
+ */
57
+ const RENDERING_KEYS = [
58
+ 'defaults',
59
+ 'fonts',
60
+ 'fontSubsets',
61
+ 'satoriOptions',
62
+ 'resvgOptions',
63
+ 'sharpOptions',
64
+ 'emojiStrategy',
65
+ 'browser'
66
+ ] as const;
67
+
68
+ export interface DuxtOgImageFingerprintInputs {
69
+ /** The site's `ogImage` config, as it is about to be handed to the module. */
70
+ options?: Record<string, unknown>;
71
+ /** Version per package, for the packages that do the rendering. */
72
+ dependencies?: Record<string, string | undefined>;
73
+ }
74
+
75
+ /**
76
+ * A digest of everything the module's own build-cache key leaves out.
77
+ *
78
+ * Two inputs, and the split is what keeps it honest: what the SITE declared,
79
+ * narrowed to the keys above, and what is INSTALLED to do the rendering. A
80
+ * fingerprint over the whole `ogImage` object would move whenever anyone
81
+ * touched a timeout; one over the lockfile would move on every Dependabot
82
+ * merge. Both are cheap to write and expensive to live with.
83
+ *
84
+ * Sixteen hex characters, because this names a directory and appears in a cache
85
+ * key a person has to read in a log. It is not a security boundary — a
86
+ * collision costs a stale image on a site the collider already controls.
87
+ */
88
+ export function duxtOgImageFingerprint(
89
+ inputs: DuxtOgImageFingerprintInputs
90
+ ): string {
91
+ const options = inputs.options ?? {};
92
+ const rendering: Record<string, unknown> = {};
93
+
94
+ for (const key of RENDERING_KEYS) {
95
+ if (options[key] !== undefined) rendering[key] = options[key];
96
+ }
97
+
98
+ return createHash('sha256')
99
+ .update(
100
+ JSON.stringify([stable(rendering), stable(inputs.dependencies ?? {})])
101
+ )
102
+ .digest('hex')
103
+ .slice(0, 16);
104
+ }
105
+
106
+ /** The same value whatever order its keys were written in. */
107
+ function stable(value: unknown): unknown {
108
+ if (Array.isArray(value)) return value.map(stable);
109
+
110
+ if (value && typeof value === 'object') {
111
+ return Object.fromEntries(
112
+ Object.entries(value as Record<string, unknown>)
113
+ .filter(([, entry]) => entry !== undefined)
114
+ .sort(([left], [right]) => left.localeCompare(right))
115
+ .map(([key, entry]) => [key, stable(entry)])
116
+ );
117
+ }
118
+
119
+ return value;
120
+ }
121
+
122
+ /** Where the rendered images land, relative to the site's root. */
123
+ export const DUXT_OG_IMAGE_CACHE_DIR = '.cache/og-image';
124
+
125
+ /**
126
+ * The name of the stamp, and why it starts with a dot.
127
+ *
128
+ * nuxt-og-image's own `prerender:done` cleanup walks this directory, skips
129
+ * every name starting with `.`, and `JSON.parse`s the rest as a cache entry. A
130
+ * stamp written under any other name would crash ITS cleanup rather than ours.
131
+ */
132
+ const STAMP = '.duxt-fingerprint';
133
+
134
+ export interface DuxtOgImageBuildCacheOptions {
135
+ /** The site being built — where `nuxt-og-image` resolves `base` against. */
136
+ rootDir: string;
137
+ /** The digest from `duxtOgImageFingerprint`. */
138
+ fingerprint: string;
139
+ /** Where the images go, relative to `rootDir`. */
140
+ dir?: string;
141
+ }
142
+
143
+ export interface DuxtOgImageBuildCache {
144
+ /** What `ogImage.buildCache.base` takes: relative to the site's root. */
145
+ base: string;
146
+ /** The same directory, absolute, for whoever has to read it. */
147
+ dir: string;
148
+ /** The fingerprint the directory now holds. */
149
+ fingerprint: string;
150
+ /** Whether this call threw away a previous build's images. */
151
+ reset: boolean;
152
+ }
153
+
154
+ /**
155
+ * The directory nuxt-og-image may keep rendered images in, emptied when the
156
+ * inputs its own key does not cover have moved.
157
+ *
158
+ * ONE NAMESPACE, NOT ONE PER FINGERPRINT. A directory per digest never serves a
159
+ * stale image either, and grows without bound on a machine that has built the
160
+ * site more than twice — every superseded font, every reverted renderer option,
161
+ * a thousand images each. Clearing in place keeps exactly one generation, which
162
+ * is the only one any build can use.
163
+ *
164
+ * Deleting a cache when its inputs moved is what a cache IS, so this runs from
165
+ * `nuxt.config.ts` without ceremony. The directory holds nothing that is not
166
+ * reproducible by rendering it again.
167
+ */
168
+ export function duxtOgImageBuildCache(
169
+ options: DuxtOgImageBuildCacheOptions
170
+ ): DuxtOgImageBuildCache {
171
+ const base = options.dir ?? DUXT_OG_IMAGE_CACHE_DIR;
172
+ const dir = join(options.rootDir, base);
173
+ const stamp = join(dir, STAMP);
174
+
175
+ // Read the stamp rather than ask whether it is there. An absent stamp and an
176
+ // unreadable one say the same thing here — nothing is proven about what this
177
+ // directory holds — and a question asked before the read is answered about a
178
+ // moment that has already passed by the time the read runs.
179
+ let held = '';
180
+ try {
181
+ held = readFileSync(stamp, 'utf8').trim();
182
+ } catch (error) {
183
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
184
+ }
185
+
186
+ // AN UNSTAMPED DIRECTORY IS EMPTIED, and that is the rule rather than an edge
187
+ // case it falls through. A cache a killed build half-wrote, or one someone
188
+ // deleted the stamp out of, holds images whose fonts and renderer nobody can
189
+ // name — the silent wrong image this whole file exists to prevent. `held` is
190
+ // '' there, which no fingerprint equals, so it goes.
191
+ const stale = held !== options.fingerprint;
192
+ const reset = stale && discardOgImageCacheDir(dir);
193
+
194
+ mkdirSync(dir, { recursive: true });
195
+ if (stale) writeFileSync(stamp, options.fingerprint);
196
+
197
+ return { base, dir, fingerprint: options.fingerprint, reset };
198
+ }
199
+
200
+ /**
201
+ * Empty the cache directory, and report whether there was one to empty.
202
+ *
203
+ * `reset` is a claim about what this call DID — that a previous build's images
204
+ * were thrown away — so it is taken from the removal itself rather than from an
205
+ * `existsSync` before it: a check answers about a moment that has passed by the
206
+ * time the removal runs, while the removal answers about its own. `force` is
207
+ * off for exactly that reason. It is the flag that turns "there was nothing
208
+ * here" from an answer into silence, and that answer is the whole return value.
209
+ */
210
+ function discardOgImageCacheDir(dir: string): boolean {
211
+ try {
212
+ rmSync(dir, { recursive: true });
213
+ return true;
214
+ } catch (error) {
215
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return false;
216
+ throw error;
217
+ }
218
+ }
219
+
220
+ export interface DuxtOgImageCacheReportInput {
221
+ /** What the cache directory holds now, one entry per rendered image. */
222
+ entries: { name: string; mtimeMs: number }[];
223
+ /** When the build started, in epoch milliseconds. */
224
+ since: number;
225
+ /** How many OG images the build wrote into its output. */
226
+ images: number;
227
+ /** How many renders the build gave up on. */
228
+ timeouts: number;
229
+ }
230
+
231
+ export interface DuxtOgImageCacheReport {
232
+ /** Entries a previous build wrote and this one carried in. */
233
+ restored: number;
234
+ /** Entries this build wrote, which is what it actually rendered. */
235
+ rendered: number;
236
+ /** Images a PREVIOUS build rendered and this one did not have to. */
237
+ reused: number;
238
+ /** Images this build was asked for twice and answered itself. */
239
+ repeated: number;
240
+ /** Images in the build's output. */
241
+ images: number;
242
+ /** Renders that ran out of time, and therefore pages with no image. */
243
+ timeouts: number;
244
+ /** `reused / images`, and zero when there were no images. */
245
+ hitRate: number;
246
+ /** Whether every image the build asked for came out of it. */
247
+ complete: boolean;
248
+ }
249
+
250
+ /**
251
+ * What the cache saved, counted from the directory rather than claimed.
252
+ *
253
+ * A HIT LEAVES NO TRACE, which is what makes this indirect. nuxt-og-image reads
254
+ * a cached image and returns it without touching the file, so there is no hit
255
+ * counter anywhere to read; what IS visible is which entries the build wrote,
256
+ * because writing one sets its mtime. Everything older than the build started
257
+ * came in with the cache and everything newer was rendered here.
258
+ *
259
+ * NOT EVERY IMAGE THAT WAS NOT RENDERED WAS REUSED, and getting that wrong is
260
+ * how a report flatters itself. A cold build of this site wrote 281 images out
261
+ * of 237 renders with nothing restored: the other 44 were the same image asked
262
+ * for twice — one locale's page linking another's — and answered from the
263
+ * build's own store, which exists whether or not anything persists between
264
+ * runs. Counting them as reuse reported 16% on a build that reused nothing.
265
+ *
266
+ * So reuse is capped by what was actually carried in, and the remainder is
267
+ * named rather than folded in. `images = rendered + reused + repeated` holds in
268
+ * every case, which is the property that makes the table readable.
269
+ *
270
+ * WHERE THAT SPLIT IS STILL A GUESS: a build that changed the TEMPLATE carries
271
+ * in entries it cannot use, because the template's source is in the module's
272
+ * own key. Restored is then large and none of it was readable, so the repeats
273
+ * are attributed to the cache instead — 44 of 281 on a measured run. `rendered`
274
+ * is the number that is never ambiguous, and a build showing `rendered` and
275
+ * `restored` both large is one whose cache was superseded rather than reused.
276
+ */
277
+ export function duxtOgImageCacheReport(
278
+ input: DuxtOgImageCacheReportInput
279
+ ): DuxtOgImageCacheReport {
280
+ let restored = 0;
281
+ let rendered = 0;
282
+
283
+ for (const entry of input.entries) {
284
+ if (entry.mtimeMs >= input.since) rendered++;
285
+ else restored++;
286
+ }
287
+
288
+ // An image rendered with NO cache write behind it — the module only writes an
289
+ // entry for an image that carries a max age — would otherwise read as a
290
+ // negative count, so the floor is here rather than in the subtraction below.
291
+ const unrendered = Math.max(0, input.images - rendered);
292
+ const reused = Math.min(restored, unrendered);
293
+
294
+ return {
295
+ restored,
296
+ rendered,
297
+ reused,
298
+ repeated: unrendered - reused,
299
+ images: input.images,
300
+ timeouts: input.timeouts,
301
+ hitRate: input.images ? reused / input.images : 0,
302
+ complete: input.timeouts === 0
303
+ };
304
+ }
305
+
306
+ /**
307
+ * Bumped when what lands in the cache directory changes SHAPE rather than
308
+ * content — the stamp's name, the entry format nuxt-og-image writes. A restored
309
+ * entry a newer layer no longer understands is worse than no cache, and this
310
+ * makes that a miss instead of a puzzle.
311
+ */
312
+ const KEY_VERSION = 'v1';
313
+
314
+ /** The heredoc marker `--github` wraps its one multi-line value in. */
315
+ const OUTPUT_DELIMITER = '__DUXT_OG_CACHE__';
316
+
317
+ export interface DuxtOgImageCacheKey {
318
+ /** The key this run saves under. */
319
+ key: string;
320
+ /** The prefixes this run restores from, newest entry first. */
321
+ restoreKeys: string[];
322
+ }
323
+
324
+ /**
325
+ * The key a CI run carries the rendered images under.
326
+ *
327
+ * A ROLLING KEY, where the download cache next door uses an exact one — and the
328
+ * difference is what the directory holds. `.data/content` is a function of the
329
+ * source list: pin the list, and one save serves every later run. This
330
+ * directory is a function of the PAGES, so a commit that adds one adds an image
331
+ * and every build legitimately has something new to save. An exact key would
332
+ * hit, never re-save, and freeze the cache at whatever the first build wrote.
333
+ *
334
+ * So the key carries the run and `restore-keys` carries the reuse — and the
335
+ * FINGERPRINT sits in front of both. A font or a renderer option that moved
336
+ * must not merely empty the directory locally: it has to miss the restore as
337
+ * well, or every build would download a tarball of images it is about to throw
338
+ * away.
339
+ */
340
+ export function duxtOgImageCacheKey(
341
+ fingerprint: string,
342
+ run: string
343
+ ): DuxtOgImageCacheKey {
344
+ const prefix = `duxt-og-image-${KEY_VERSION}-${fingerprint}-`;
345
+
346
+ return { key: `${prefix}${run}`, restoreKeys: [prefix] };
347
+ }
348
+
349
+ export interface DuxtOgImageCacheLocation extends DuxtOgImageCacheKey {
350
+ /** What `ogImage.buildCache.base` takes: relative to the site's root. */
351
+ base: string;
352
+ /** The same directory, absolute. */
353
+ dir: string;
354
+ /** The directory, relative to where the COMMAND ran rather than the site. */
355
+ path: string;
356
+ /** The digest the directory is namespaced by. */
357
+ fingerprint: string;
358
+ /** Whether a build emptied it on the way past. Absent for a read-only look. */
359
+ reset?: boolean;
360
+ }
361
+
362
+ /**
363
+ * The key step's two shapes: `name=value` lines for a workflow, prose for a
364
+ * person asking why their cache missed.
365
+ *
366
+ * ALWAYS EXIT 0, for the reason the download cache states: "there is nothing to
367
+ * reuse here" is an answer, not a failure.
368
+ */
369
+ export function formatDuxtOgImageCacheKey(
370
+ argv: string[],
371
+ data: DuxtOgImageCacheLocation
372
+ ): { output: string; exitCode: number } {
373
+ if (argv.includes('--github')) {
374
+ return {
375
+ output: [
376
+ `key=${data.key}`,
377
+ `path=${data.path}`,
378
+ `fingerprint=${data.fingerprint}`,
379
+ `restore-keys<<${OUTPUT_DELIMITER}`,
380
+ ...data.restoreKeys,
381
+ OUTPUT_DELIMITER
382
+ ].join('\n'),
383
+ exitCode: 0
384
+ };
385
+ }
386
+
387
+ return {
388
+ output: [
389
+ `key ${data.key}`,
390
+ `restore ${data.restoreKeys.join(', ')}`,
391
+ `path ${data.path}`,
392
+ `inputs ${data.fingerprint}`,
393
+ data.reset
394
+ ? 'reset yes — the rendering inputs moved, so the directory was emptied'
395
+ : 'reset no'
396
+ ].join('\n'),
397
+ exitCode: 0
398
+ };
399
+ }
400
+
401
+ export interface DuxtOgImageTimings {
402
+ /** How long the Nuxt build took, in milliseconds. */
403
+ buildMs?: number;
404
+ /** How long wrangler took to upload it, in milliseconds. */
405
+ uploadMs?: number;
406
+ }
407
+
408
+ /**
409
+ * The report, in the two shapes anyone asks for it.
410
+ *
411
+ * BUILD AND UPLOAD ARE SEPARATE NUMBERS, which is the only reason this takes
412
+ * timings at all. A single "deploy took N" cannot say whether a cache helped:
413
+ * the upload is a function of the output's size and moves for reasons that have
414
+ * nothing to do with rendering. Split, the build half is comparable between a
415
+ * cold run and a warm one, and the upload half is the control.
416
+ */
417
+ export function formatDuxtOgImageCacheReport(
418
+ argv: string[],
419
+ report: DuxtOgImageCacheReport,
420
+ timings: DuxtOgImageTimings = {}
421
+ ): { output: string; exitCode: number } {
422
+ const percent = `${Math.round(report.hitRate * 100)}%`;
423
+
424
+ if (argv.includes('--github')) {
425
+ return {
426
+ output: [
427
+ `images=${report.images}`,
428
+ `rendered=${report.rendered}`,
429
+ `reused=${report.reused}`,
430
+ `repeated=${report.repeated}`,
431
+ `restored=${report.restored}`,
432
+ `timeouts=${report.timeouts}`,
433
+ `hit-rate=${percent}`,
434
+ `complete=${report.complete}`
435
+ ].join('\n'),
436
+ exitCode: 0
437
+ };
438
+ }
439
+
440
+ const rows: [string, string][] = [
441
+ ['OG images in the output', String(report.images)],
442
+ ['Reused from the cache', `${report.reused} (${percent})`],
443
+ ['Rendered this build', String(report.rendered)],
444
+ ['Served twice from this build', String(report.repeated)],
445
+ ['Entries restored', String(report.restored)]
446
+ ];
447
+
448
+ if (timings.buildMs !== undefined)
449
+ rows.push(['Build', duration(timings.buildMs)]);
450
+ if (timings.uploadMs !== undefined)
451
+ rows.push(['Upload', duration(timings.uploadMs)]);
452
+
453
+ const lines = [
454
+ '### 🖼️ OG image cache',
455
+ '',
456
+ '| Measure | Value |',
457
+ '|---------|-------|',
458
+ ...rows.map(([name, value]) => `| ${name} | ${value} |`)
459
+ ];
460
+
461
+ // Not a table row: a page whose render timed out ships with no image at all,
462
+ // and nothing else in the build says so. It is the one line here that is a
463
+ // finding rather than a measurement.
464
+ if (!report.complete) {
465
+ lines.push(
466
+ '',
467
+ `> [!WARNING]`,
468
+ `> ${report.timeouts} render${report.timeouts === 1 ? '' : 's'} timed out, so that many pages carry no OG image.`
469
+ );
470
+ }
471
+
472
+ return { output: lines.join('\n'), exitCode: 0 };
473
+ }
474
+
475
+ /** Milliseconds as something a person reads off a summary. */
476
+ function duration(ms: number): string {
477
+ const seconds = Math.round(ms / 1000);
478
+
479
+ return seconds < 60
480
+ ? `${seconds}s`
481
+ : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
482
+ }
483
+
484
+ /**
485
+ * The packages whose version decides what a render comes out looking like.
486
+ *
487
+ * `nuxt-og-image` is in here even though its own build-cache key already
488
+ * carries its version: the key is theirs to change, this is the layer's
489
+ * statement about what the cache depends on, and a fingerprint that is only
490
+ * correct while an upstream implementation detail holds is not one.
491
+ *
492
+ * Everything else in the graph is deliberately absent. A lockfile digest would
493
+ * be simpler and would throw the cache away on every Dependabot merge — a cold
494
+ * build a week for packages that cannot reach a pixel.
495
+ */
496
+ const RENDERER_PACKAGES = [
497
+ 'nuxt-og-image',
498
+ 'satori',
499
+ '@resvg/resvg-js',
500
+ 'sharp',
501
+ '@takumi-rs/core'
502
+ ];
503
+
504
+ /**
505
+ * The installed versions of the renderers, read out of the pnpm lockfile.
506
+ *
507
+ * NOT `require.resolve`, which cannot see them. `nuxt-og-image` arrives as a
508
+ * dependency of `@nuxtjs/seo` and `satori` as a dependency of that, so under
509
+ * pnpm's isolated linker neither sits anywhere a resolver starting at the site
510
+ * — or at this file — would look. The lockfile is the one place that names
511
+ * every version regardless of who depends on it.
512
+ *
513
+ * A site installed with something other than pnpm gets an empty record and
514
+ * therefore a fingerprint over its configuration alone. That is a smaller net
515
+ * than this repo's, and it is stated rather than pretended: `nuxt-og-image`'s
516
+ * own key still carries its own version, so what is lost is invalidation on a
517
+ * `satori` bump.
518
+ */
519
+ export function duxtOgImageRendererVersions(
520
+ from: string,
521
+ packages: string[] = RENDERER_PACKAGES
522
+ ): Record<string, string> {
523
+ let dir = resolve(from);
524
+ let lockfile = '';
525
+
526
+ for (;;) {
527
+ const candidate = join(dir, 'pnpm-lock.yaml');
528
+
529
+ if (existsSync(candidate)) {
530
+ lockfile = readFileSync(candidate, 'utf8');
531
+ break;
532
+ }
533
+
534
+ const parent = dirname(dir);
535
+ if (parent === dir) return {};
536
+ dir = parent;
537
+ }
538
+
539
+ const versions: Record<string, string> = {};
540
+
541
+ for (const name of packages) {
542
+ // ` name@version:` under `packages:`, and ` name@version(peers):` under
543
+ // `snapshots:` — the same version either way, so the peer suffix is cut
544
+ // rather than matched, and a package installed at two versions is reported
545
+ // as both rather than as whichever came first.
546
+ //
547
+ // The optional quote is not cosmetic: pnpm quotes every key beginning with
548
+ // `@`, which is every scoped package, which is the native resvg binding.
549
+ const found = new Set<string>();
550
+ const pattern = new RegExp(
551
+ `^ {2}'?${name.replaceAll(/[.*+?^${}()|[\]\\]/g, '\\$&')}@([^(:\\s']+)`,
552
+ 'gm'
553
+ );
554
+
555
+ for (const match of lockfile.matchAll(pattern)) found.add(match[1]!);
556
+
557
+ if (found.size) versions[name] = [...found].sort().join(',');
558
+ }
559
+
560
+ return versions;
561
+ }
562
+
563
+ export interface DuxtOgImageCacheOptions {
564
+ /** The site being built — where `.cache/` and `.output/` live. */
565
+ rootDir?: string;
566
+ /** What makes this run's key unique. A commit sha in CI. */
567
+ run?: string;
568
+ }
569
+
570
+ /**
571
+ * Where the cache is and what it is keyed under, WITHOUT touching it.
572
+ *
573
+ * The workflow's key step runs before the cache is restored, so it must not
574
+ * write the stamp — and it cannot compute the full fingerprint either: a site's
575
+ * renderer options live in its `nuxt.config.ts`, and a command that loaded that
576
+ * would claim the Nuxt process it is standing beside.
577
+ *
578
+ * So the KEY is namespaced by the renderer versions alone and the DIRECTORY is
579
+ * stamped with everything, which is the right way round. The stamp is what
580
+ * decides whether an image may be served, and it is read by the build, which
581
+ * knows all of it. The key only decides whether a tarball is worth downloading;
582
+ * being too generous there costs one download of a directory the build then
583
+ * empties, and being too strict would cost every build its cache.
584
+ */
585
+ export function duxtOgImageCacheLocation(
586
+ options: DuxtOgImageCacheOptions = {}
587
+ ): DuxtOgImageCacheLocation {
588
+ const rootDir = resolve(options.rootDir ?? process.cwd());
589
+ const fingerprint = duxtOgImageFingerprint({
590
+ dependencies: duxtOgImageRendererVersions(rootDir)
591
+ });
592
+
593
+ const base = DUXT_OG_IMAGE_CACHE_DIR;
594
+ const dir = join(rootDir, base);
595
+ // Reported relative to where the COMMAND ran, not to the site: a workflow
596
+ // hands this straight to a cache action, whose paths are workspace-relative.
597
+ const here = relative(process.cwd(), dir);
598
+
599
+ return {
600
+ base,
601
+ dir,
602
+ path: here && !here.startsWith('..') ? here : dir,
603
+ fingerprint,
604
+ ...duxtOgImageCacheKey(fingerprint, options.run ?? 'local')
605
+ };
606
+ }
607
+
608
+ export interface DuxtOgImageCacheStateOptions extends DuxtOgImageCacheOptions {
609
+ /** When the build started, in epoch milliseconds. */
610
+ since: number;
611
+ /** The captured build log, for the renders that ran out of time. */
612
+ log?: string;
613
+ }
614
+
615
+ /** What nuxt-og-image logs for a render that ran out of its budget. */
616
+ const TIMEOUT_LINE = /renderer\.createImage timeout/g;
617
+
618
+ /**
619
+ * Where the prerender pass puts the images, and why this is a DIRECTORY rather
620
+ * than a filename.
621
+ *
622
+ * nuxt-og-image 6 writes `_og/s/<hash>.png` — or `_og/s/<encoded params>.png`
623
+ * for an unsigned one — so there is no fixed filename to match on. v3 wrote
624
+ * `__og-image__/image/<route>/og.png`, which is what a counter written from
625
+ * memory looks for and what it finds none of. Both prefixes are here so the
626
+ * count does not silently go to zero on either side of that change.
627
+ */
628
+ const IMAGE_DIRS = new Set(['_og', '__og-image__']);
629
+
630
+ /** An image, as opposed to the payloads and HTML written all around it. */
631
+ const IMAGE_FILE = /\.(png|jpe?g)$/;
632
+
633
+ /**
634
+ * The build, counted off the disk it left behind.
635
+ *
636
+ * Everything here is a READ: the cache directory for what was written when, the
637
+ * output for what came out, the log for what did not. Nothing in a report may
638
+ * change the thing it reports on.
639
+ */
640
+ export function duxtOgImageCacheState(
641
+ options: DuxtOgImageCacheStateOptions
642
+ ): DuxtOgImageCacheReport {
643
+ const rootDir = resolve(options.rootDir ?? process.cwd());
644
+ const dir = join(rootDir, DUXT_OG_IMAGE_CACHE_DIR);
645
+
646
+ const entries = existsSync(dir)
647
+ ? readdirSync(dir)
648
+ // The stamp is not an image, and upstream's own cleanup skips it by the
649
+ // same rule. Counting it would report one render that never happened.
650
+ .filter((name) => !name.startsWith('.'))
651
+ .map((name) => ({ name, mtimeMs: statSync(join(dir, name)).mtimeMs }))
652
+ : [];
653
+
654
+ const log =
655
+ options.log && existsSync(options.log)
656
+ ? readFileSync(options.log, 'utf8')
657
+ : '';
658
+
659
+ return duxtOgImageCacheReport({
660
+ entries,
661
+ since: options.since,
662
+ images: countImages(join(rootDir, '.output/public')),
663
+ timeouts: [...log.matchAll(TIMEOUT_LINE)].length
664
+ });
665
+ }
666
+
667
+ /**
668
+ * Every rendered OG image under a built site.
669
+ *
670
+ * Scoped to the module's own output directory rather than counting every PNG
671
+ * in the build: a site's favicon, its apple-touch-icon and whatever `@nuxt/
672
+ * image` produced are all PNGs, and none of them was rendered here.
673
+ */
674
+ function countImages(publicDir: string): number {
675
+ let found = 0;
676
+
677
+ for (const name of IMAGE_DIRS) {
678
+ const dir = join(publicDir, name);
679
+ if (existsSync(dir)) found += countImageFiles(dir);
680
+ }
681
+
682
+ return found;
683
+ }
684
+
685
+ /** Image files under one directory, however deep the route nested them. */
686
+ function countImageFiles(dir: string): number {
687
+ let found = 0;
688
+
689
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
690
+ if (entry.isDirectory()) found += countImageFiles(join(dir, entry.name));
691
+ else if (IMAGE_FILE.test(entry.name)) found++;
692
+ }
693
+
694
+ return found;
695
+ }
696
+
697
+ /**
698
+ * The command, dispatched on its one flag.
699
+ *
700
+ * Here rather than in `bin/duxt-og-cache.mjs` because that file is plain
701
+ * JavaScript for a resolver's sake, and a decision made there is a decision
702
+ * nothing typechecks and no test reaches.
703
+ */
704
+ export function duxtOgImageCacheCommand(argv: string[]): {
705
+ output: string;
706
+ exitCode: number;
707
+ } {
708
+ const value = (flag: string) => {
709
+ const index = argv.indexOf(flag);
710
+ return index === -1 ? undefined : argv[index + 1];
711
+ };
712
+ const ms = (flag: string) => {
713
+ const raw = value(flag);
714
+
715
+ // An EMPTY string, not merely a missing flag. A workflow passing the
716
+ // elapsed time of a step that never ran hands over `--build-ms ''`, and
717
+ // `Number('')` is a perfectly finite zero — which would report a build
718
+ // that failed as one that took no time at all.
719
+ return raw === undefined || raw === '' || !Number.isFinite(Number(raw))
720
+ ? undefined
721
+ : Number(raw);
722
+ };
723
+
724
+ const rootDir = value('--root');
725
+
726
+ if (argv.includes('--report')) {
727
+ return formatDuxtOgImageCacheReport(
728
+ argv,
729
+ duxtOgImageCacheState({
730
+ rootDir,
731
+ since: ms('--since') ?? 0,
732
+ log: value('--log')
733
+ }),
734
+ { buildMs: ms('--build-ms'), uploadMs: ms('--upload-ms') }
735
+ );
736
+ }
737
+
738
+ return formatDuxtOgImageCacheKey(
739
+ argv,
740
+ duxtOgImageCacheLocation({ rootDir, run: value('--run') })
741
+ );
742
+ }