diff --git a/app/components/content/ProseImg.vue b/app/components/content/ProseImg.vue
index 89a5fef..4155ed4 100644
--- a/app/components/content/ProseImg.vue
+++ b/app/components/content/ProseImg.vue
@@ -1,11 +1,16 @@
@@ -28,7 +39,7 @@ const isSvg = computed(() => props.src?.toLowerCase().endsWith('.svg'))
v-else
:src="src"
:alt="alt"
- :width="width ?? 1280"
+ :width="cappedWidth"
:height="height"
format="webp"
quality="100"
diff --git a/nuxt.config.ts b/nuxt.config.ts
index f87b046..3c46a95 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,8 +1,9 @@
import { fileURLToPath } from 'node:url'
import { execFileSync } from 'node:child_process'
import { readdirSync, readFileSync } from 'node:fs'
-import { join } from 'node:path'
+import { extname, join } from 'node:path'
import { createResolver } from '@nuxt/kit'
+import { imageSize } from 'image-size'
const { resolve } = createResolver(import.meta.url)
@@ -66,6 +67,34 @@ function scanContentIcons(): { collections: string[], icons: string[] } {
}
const contentIcons = scanContentIcons()
+// ProseImg (app/components/content/ProseImg.vue) caps every markdown image
+// at 1280px wide, but NuxtImg's `width` prop always becomes the rendered
+//
's HTML width attribute too, not just the resize target. Passing
+// 1280 unconditionally would force a smaller source (a 1024px screenshot,
+// say) to stretch up to fill that width in the browser, blurry on every
+// display. Reading each image's real width here once at build time lets
+// ProseImg pass through `min(natural, 1280)` instead, so undersized images
+// are only re-encoded, never stretched.
+function scanImageWidths(): Record {
+ const widths: Record = {}
+ const raster = new Set(['.png', '.jpg', '.jpeg', '.webp'])
+ function walk(dir: string) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name)
+ if (entry.isDirectory()) {
+ walk(full)
+ }
+ else if (raster.has(extname(entry.name).toLowerCase())) {
+ const { width } = imageSize(readFileSync(full))
+ const publicPath = full.slice(full.indexOf('/public/') + '/public'.length)
+ widths[publicPath] = width
+ }
+ }
+ }
+ walk(resolve('./public/img'))
+ return widths
+}
+
// None of the top-level content sections has a landing page of its own,
// just a first numbered article inside the folder, so the bare section URL
// (/en/serveex/) has nothing to serve on the static build. Deriving the
@@ -157,6 +186,13 @@ export default defineNuxtConfig({
// Every single /_ipx/* request 404s (IPX_FILE_NOT_FOUND) otherwise.
dir: fileURLToPath(new URL('./public', import.meta.url)),
},
+ runtimeConfig: {
+ public: {
+ // Read by ProseImg to cap each image's width at its own real size
+ // instead of always requesting (and rendering) 1280px.
+ imageWidths: scanImageWidths(),
+ },
+ },
icon: {
// This site builds to a fully static export (nginx serving prerendered
// files, no Nitro server at runtime), so the `/api/_nuxt_icon` route
diff --git a/package-lock.json b/package-lock.json
index 7de7cb3..e6974dc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -16,6 +16,7 @@
"@nuxtjs/i18n": "^10.6.0",
"better-sqlite3": "^12.11.1",
"docus": "^5.13.0",
+ "image-size": "^2.0.2",
"nuxt": "^4.4.8"
}
},
@@ -11449,6 +11450,18 @@
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
"license": "MIT"
},
+ "node_modules/image-size": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz",
+ "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==",
+ "license": "MIT",
+ "bin": {
+ "image-size": "bin/image-size.js"
+ },
+ "engines": {
+ "node": ">=16.x"
+ }
+ },
"node_modules/import-meta-resolve": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
diff --git a/package.json b/package.json
index 8f0720b..eed7bc0 100644
--- a/package.json
+++ b/package.json
@@ -16,6 +16,7 @@
"@nuxtjs/i18n": "^10.6.0",
"better-sqlite3": "^12.11.1",
"docus": "^5.13.0",
+ "image-size": "^2.0.2",
"nuxt": "^4.4.8"
}
}