@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.
- package/README.md +24 -9
- package/app/app.vue +17 -11
- package/app/assets/css/duxt.css +47 -6
- package/app/components/DuxtAnnouncements.vue +117 -0
- package/app/components/DuxtBrunoEntries.vue +62 -0
- package/app/components/DuxtCodeBlock.vue +18 -34
- package/app/components/DuxtCodeToolbar.vue +14 -0
- package/app/components/DuxtCopyButton.vue +33 -0
- package/app/components/DuxtCopyPage.vue +32 -30
- package/app/components/DuxtFooter.vue +39 -8
- package/app/components/DuxtHeader.vue +107 -48
- package/app/components/DuxtLandingProse.vue +1 -1
- package/app/components/DuxtLinkLabel.vue +36 -0
- package/app/components/DuxtLiveWindow.vue +8 -8
- package/app/components/DuxtLocale.vue +10 -1
- package/app/components/DuxtNavigation.vue +2 -2
- package/app/components/DuxtOpenApiClient.vue +285 -90
- package/app/components/DuxtOpenApiMedia.vue +30 -18
- package/app/components/DuxtOpenApiSchema.vue +10 -7
- package/app/components/DuxtPageFeedback.vue +7 -0
- package/app/components/DuxtPageInfo.vue +15 -12
- package/app/components/DuxtPageNav.vue +41 -27
- package/app/components/DuxtPreviewSkeleton.vue +4 -4
- package/app/components/DuxtProgress.vue +2 -14
- package/app/components/DuxtSearch.vue +230 -90
- package/app/components/DuxtSearchInput.vue +70 -0
- package/app/components/DuxtSections.vue +11 -1
- package/app/components/DuxtSegmented.vue +64 -0
- package/app/components/DuxtShortcuts.vue +16 -11
- package/app/components/DuxtShortcutsTrigger.vue +49 -0
- package/app/components/DuxtSkipLink.vue +1 -1
- package/app/components/DuxtToc.vue +16 -10
- package/app/components/DuxtTranslationBanner.vue +1 -1
- package/app/components/DuxtVersion.vue +53 -12
- package/app/components/DuxtVersionBanner.vue +5 -5
- package/app/components/content/AccordionItem.vue +2 -2
- package/app/components/content/BrunoOverview.vue +260 -0
- package/app/components/content/BrunoRequest.vue +141 -0
- package/app/components/content/BrunoRequests.vue +104 -0
- package/app/components/content/Callout.vue +5 -5
- package/app/components/content/ChangelogReleases.vue +161 -40
- package/app/components/content/CodeGroup.vue +41 -37
- package/app/components/content/Field.vue +1 -1
- package/app/components/content/FileTree.vue +1 -1
- package/app/components/content/OpenApiOperation.vue +4 -4
- package/app/components/content/OpenApiOperations.vue +1 -1
- package/app/components/content/OpenApiOverview.vue +1 -1
- package/app/components/content/PackageManagers.vue +42 -58
- package/app/components/content/PageCards.vue +7 -4
- package/app/components/content/Preview.vue +15 -39
- package/app/components/content/ProseImg.vue +596 -49
- package/app/components/content/Since.vue +1 -1
- package/app/components/content/Steps.vue +6 -4
- package/app/components/content/TabGroup.vue +13 -12
- package/app/components/ui/breadcrumb/BreadcrumbSeparator.vue +1 -1
- package/app/components/ui/command/CommandShortcut.vue +1 -1
- package/app/components/ui/dialog/DialogContent.vue +2 -2
- package/app/components/ui/dialog/DialogHeader.vue +1 -1
- package/app/components/ui/dialog/DialogScrollContent.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuItem.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuLabel.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +2 -2
- package/app/components/ui/dropdown-menu/DropdownMenuShortcut.vue +1 -1
- package/app/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +2 -2
- package/app/components/ui/navigation-menu/NavigationMenuContent.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuTrigger.vue +1 -1
- package/app/components/ui/navigation-menu/NavigationMenuViewport.vue +1 -1
- package/app/components/ui/scroll-area/ScrollBar.vue +1 -1
- package/app/components/ui/select/SelectItem.vue +2 -2
- package/app/components/ui/sheet/SheetContent.vue +29 -3
- package/app/components/ui/sidebar/Sidebar.vue +28 -4
- package/app/components/ui/sidebar/SidebarGroupAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarInset.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuAction.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuBadge.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSub.vue +1 -1
- package/app/components/ui/sidebar/SidebarMenuSubButton.vue +1 -1
- package/app/components/ui/sidebar/SidebarProvider.vue +7 -16
- package/app/components/ui/sidebar/index.ts +10 -2
- package/app/components/ui/sidebar/utils.ts +0 -1
- package/app/components/ui/sonner/Sonner.vue +18 -0
- package/app/components/ui/tabs/TabsList.vue +13 -3
- package/app/components/ui/tabs/TabsTrigger.vue +24 -3
- package/app/components/ui/tooltip/TooltipContent.vue +2 -2
- package/app/composables/useActiveHeading.ts +3 -25
- package/app/composables/useDuxtAnalytics.ts +50 -0
- package/app/composables/useDuxtAnimatedHeight.ts +8 -38
- package/app/composables/useDuxtAnnouncements.ts +153 -0
- package/app/composables/useDuxtCopy.ts +51 -0
- package/app/composables/useDuxtDirection.ts +23 -0
- package/app/composables/useDuxtNavigation.ts +28 -8
- package/app/composables/useDuxtPath.ts +3 -5
- package/app/composables/useDuxtSearch.ts +87 -35
- package/app/composables/useDuxtSection.ts +15 -1
- package/app/composables/useDuxtSectionRow.ts +6 -5
- package/app/composables/useDuxtShortcuts.ts +319 -20
- package/app/composables/useDuxtVersion.ts +57 -6
- package/app/composables/useDuxtViewportMeasure.ts +37 -0
- package/app/composables/useFuzzySearch.ts +9 -4
- package/app/composables/useRecentPages.ts +14 -17
- package/app/error.vue +112 -74
- package/app/layouts/changelog.vue +9 -1
- package/app/layouts/default.vue +9 -0
- package/app/layouts/docs.vue +9 -1
- package/app/layouts/reference.vue +9 -1
- package/app/middleware/duxt-section-layout.global.ts +1 -3
- package/app/pages/[...slug].vue +146 -35
- package/app/pages/index.vue +15 -23
- package/app/types/duxt.d.ts +312 -4
- package/app/utils/analytics.ts +91 -0
- package/app/utils/announcements.ts +205 -0
- package/app/utils/changelog.ts +0 -28
- package/app/utils/code-highlight.ts +3 -1
- package/app/utils/contributor-avatar.ts +27 -0
- package/app/utils/direction.ts +43 -0
- package/app/utils/duxt-config.ts +43 -0
- package/app/utils/duxt-text.ts +10 -0
- package/app/utils/format-date.ts +28 -0
- package/app/utils/generated-sections.ts +68 -12
- package/app/utils/generated-toc.ts +14 -7
- package/app/utils/locale-path.ts +43 -0
- package/app/utils/navigation-tree.ts +51 -1
- package/app/utils/nearest-page.ts +8 -0
- package/app/utils/openapi.ts +16 -11
- package/app/utils/page-controls.ts +215 -0
- package/app/utils/pill.ts +61 -0
- package/app/utils/prose-image.ts +128 -0
- package/app/utils/search-context.ts +110 -0
- package/app/utils/search-display.ts +248 -0
- package/app/utils/section-scope.ts +65 -4
- package/app/utils/stored-json.ts +31 -0
- package/app/utils/version-choices.ts +6 -13
- package/app/utils/version-paths.ts +21 -0
- package/bin/duxt-cache-key.mjs +61 -0
- package/bin/duxt-og-cache.mjs +58 -0
- package/bin/{duxt-report.mjs → duxt.mjs} +13 -7
- package/bruno-model.ts +169 -0
- package/bruno-parse.ts +656 -0
- package/bruno-zip.ts +195 -0
- package/cli.ts +240 -0
- package/duxt-app-config.ts +18 -1
- package/frontmatter.ts +49 -0
- package/git-contributors.ts +322 -0
- package/i18n/locales/de/duxt/announcement.json +7 -0
- package/i18n/locales/de/duxt/bruno.json +25 -0
- package/i18n/locales/de/duxt/changelog.json +1 -1
- package/i18n/locales/de/duxt/code.json +2 -1
- package/i18n/locales/de/duxt/page.json +9 -0
- package/i18n/locales/de/duxt/shortcuts.json +1 -0
- package/i18n/locales/de/duxt/theme.json +2 -1
- package/i18n/locales/en/duxt/announcement.json +7 -0
- package/i18n/locales/en/duxt/bruno.json +25 -0
- package/i18n/locales/en/duxt/changelog.json +1 -1
- package/i18n/locales/en/duxt/code.json +2 -1
- package/i18n/locales/en/duxt/page.json +9 -0
- package/i18n/locales/en/duxt/shortcuts.json +1 -0
- package/i18n/locales/en/duxt/theme.json +2 -1
- package/i18n/locales/es/duxt/announcement.json +7 -0
- package/i18n/locales/es/duxt/bruno.json +25 -0
- package/i18n/locales/es/duxt/changelog.json +1 -1
- package/i18n/locales/es/duxt/code.json +2 -1
- package/i18n/locales/es/duxt/page.json +9 -0
- package/i18n/locales/es/duxt/shortcuts.json +1 -0
- package/i18n/locales/es/duxt/theme.json +2 -1
- package/i18n/locales/fr/duxt/announcement.json +7 -0
- package/i18n/locales/fr/duxt/bruno.json +25 -0
- package/i18n/locales/fr/duxt/changelog.json +1 -1
- package/i18n/locales/fr/duxt/code.json +2 -1
- package/i18n/locales/fr/duxt/page.json +9 -0
- package/i18n/locales/fr/duxt/shortcuts.json +1 -0
- package/i18n/locales/fr/duxt/theme.json +2 -1
- package/i18n/locales/pt/duxt/announcement.json +7 -0
- package/i18n/locales/pt/duxt/bruno.json +25 -0
- package/i18n/locales/pt/duxt/changelog.json +1 -1
- package/i18n/locales/pt/duxt/code.json +2 -1
- package/i18n/locales/pt/duxt/page.json +9 -0
- package/i18n/locales/pt/duxt/shortcuts.json +1 -0
- package/i18n/locales/pt/duxt/theme.json +2 -1
- package/modules/bruno.ts +118 -0
- package/modules/config.ts +26 -0
- package/modules/devtools.ts +2 -3
- package/modules/git-meta.ts +16 -47
- package/modules/search-records.ts +132 -0
- package/nuxt.config.ts +81 -7
- package/og-image-cache.ts +742 -0
- package/package.json +42 -10
- package/public/devtools/checks.html +2 -2
- package/public/devtools/config.html +59 -4
- package/public/devtools/pages.html +8 -1
- package/public/devtools/paths.html +1 -1
- package/public/devtools/sources.html +2 -2
- package/public/devtools/versions.html +3 -0
- package/report.ts +28 -19
- package/search-records.ts +380 -0
- package/section-input.ts +142 -0
- package/section-markdown.ts +67 -0
- package/section-reports.ts +10 -4
- package/sections-bruno.ts +571 -0
- package/sections-changelog.ts +76 -43
- package/sections-openapi.ts +12 -83
- package/sections-resolve.ts +174 -8
- package/sections.ts +33 -10
- package/server/devtools/shell.ts +1 -1
- package/server/mcp/tools/list-pages.ts +77 -36
- package/server/mcp/tools/list-versions.ts +84 -0
- package/server/mcp/tools/read-page.ts +80 -26
- package/server/mcp/tools/search-docs.ts +87 -53
- package/server/middleware/raw-markdown.ts +1 -1
- package/server/routes/llms-full.txt.get.ts +6 -8
- package/server/routes/llms.txt.get.ts +1 -1
- package/server/utils/duxt-server-text.ts +0 -13
- package/server/utils/llms-pages.ts +24 -9
- package/server/utils/mcp-docs.ts +577 -0
- package/sources-cache.ts +376 -0
- package/sources-git.ts +186 -31
- package/sources-resolve.ts +59 -1
- package/sources.ts +122 -13
- 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
|
+
}
|