Compare commits
4
Commits
0f9b039e11
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b54495a341 | ||
|
|
6691aaa02a | ||
|
|
3c0a527b43 | ||
|
|
b6ffef5a8f |
@@ -0,0 +1,47 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// Overrides Nuxt Content's default ProseImg (which renders every markdown
|
||||||
|
// image as a plain <img>, or through NuxtImg only if it happens to detect
|
||||||
|
// one registered): every raster image is re-encoded to WebP, which alone
|
||||||
|
// cuts most screenshots down substantially, and capped at 1280px wide.
|
||||||
|
// NuxtImg's `width` prop doubles as the rendered <img>'s HTML width
|
||||||
|
// attribute, not just the resize target, so passing 1280 unconditionally
|
||||||
|
// would stretch a smaller source (a 1024px screenshot, say) up to fill
|
||||||
|
// that width in the browser, blurry on every display. `imageWidths`
|
||||||
|
// (nuxt.config.ts's scanImageWidths) holds each image's real width, read
|
||||||
|
// once at build time, so undersized images are only re-encoded, not
|
||||||
|
// stretched. SVGs are already tiny vector files and would gain nothing
|
||||||
|
// from either step, so they're left untouched.
|
||||||
|
const props = defineProps<{
|
||||||
|
src?: string
|
||||||
|
alt?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const isSvg = computed(() => props.src?.toLowerCase().endsWith('.svg'))
|
||||||
|
const cappedWidth = computed(() => {
|
||||||
|
if (props.width) return props.width
|
||||||
|
const naturalWidth = props.src ? config.public.imageWidths[props.src] : undefined
|
||||||
|
return naturalWidth ? Math.min(naturalWidth, 1280) : 1280
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<img
|
||||||
|
v-if="isSvg"
|
||||||
|
:src="src"
|
||||||
|
:alt="alt"
|
||||||
|
:width="width"
|
||||||
|
:height="height"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
v-else
|
||||||
|
:src="src"
|
||||||
|
:alt="alt"
|
||||||
|
:width="cappedWidth"
|
||||||
|
:height="height"
|
||||||
|
format="webp"
|
||||||
|
quality="100"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
+68
-3
@@ -1,8 +1,9 @@
|
|||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
import { execFileSync } from 'node:child_process'
|
import { execFileSync } from 'node:child_process'
|
||||||
import { readdirSync, readFileSync } from 'node:fs'
|
import { readdirSync, readFileSync } from 'node:fs'
|
||||||
import { join } from 'node:path'
|
import { extname, join } from 'node:path'
|
||||||
import { createResolver } from '@nuxt/kit'
|
import { createResolver } from '@nuxt/kit'
|
||||||
|
import { imageSize } from 'image-size'
|
||||||
|
|
||||||
const { resolve } = createResolver(import.meta.url)
|
const { resolve } = createResolver(import.meta.url)
|
||||||
|
|
||||||
@@ -66,6 +67,59 @@ function scanContentIcons(): { collections: string[], icons: string[] } {
|
|||||||
}
|
}
|
||||||
const contentIcons = scanContentIcons()
|
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
|
||||||
|
// <img>'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<string, number> {
|
||||||
|
const widths: Record<string, number> = {}
|
||||||
|
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
|
||||||
|
// redirect target from the content tree here, instead of hand-listing each
|
||||||
|
// section, means adding, renaming or reordering a section's first article
|
||||||
|
// keeps working on its own, with nothing to update in this file or in
|
||||||
|
// SWAG's config.
|
||||||
|
function getSectionIndexRedirects(): Record<string, { redirect: { to: string, statusCode: 301 } }> {
|
||||||
|
const rules: Record<string, { redirect: { to: string, statusCode: 301 } }> = {}
|
||||||
|
for (const lang of ['en', 'fr']) {
|
||||||
|
const langDir = resolve(`./content/${lang}`)
|
||||||
|
for (const section of readdirSync(langDir, { withFileTypes: true })) {
|
||||||
|
if (!section.isDirectory()) continue
|
||||||
|
const sectionSlug = section.name.replace(/^\d+\./, '')
|
||||||
|
const firstFile = readdirSync(join(langDir, section.name), { withFileTypes: true })
|
||||||
|
.filter(entry => entry.isFile() && entry.name.endsWith('.md'))
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true }))[0]
|
||||||
|
if (!firstFile) continue
|
||||||
|
const fileSlug = firstFile.name.replace(/^\d+\./, '').replace(/\.md$/, '')
|
||||||
|
rules[`/${lang}/${sectionSlug}/`] = { redirect: { to: `/${lang}/${sectionSlug}/${fileSlug}/`, statusCode: 301 } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rules
|
||||||
|
}
|
||||||
|
|
||||||
// Contributors per page: read straight from git history rather than an
|
// Contributors per page: read straight from git history rather than an
|
||||||
// API, so it needs no token and no network call, but the CI checkout must
|
// API, so it needs no token and no network call, but the CI checkout must
|
||||||
// fetch full history (not a shallow clone) or every file will only show
|
// fetch full history (not a shallow clone) or every file will only show
|
||||||
@@ -132,6 +186,13 @@ export default defineNuxtConfig({
|
|||||||
// Every single /_ipx/* request 404s (IPX_FILE_NOT_FOUND) otherwise.
|
// Every single /_ipx/* request 404s (IPX_FILE_NOT_FOUND) otherwise.
|
||||||
dir: fileURLToPath(new URL('./public', import.meta.url)),
|
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: {
|
icon: {
|
||||||
// This site builds to a fully static export (nginx serving prerendered
|
// This site builds to a fully static export (nginx serving prerendered
|
||||||
// files, no Nitro server at runtime), so the `/api/_nuxt_icon` route
|
// files, no Nitro server at runtime), so the `/api/_nuxt_icon` route
|
||||||
@@ -219,8 +280,10 @@ export default defineNuxtConfig({
|
|||||||
// `/robots.txt` is ever written and both 404 on a static host — `/` loses
|
// `/robots.txt` is ever written and both 404 on a static host — `/` loses
|
||||||
// the redirect to the default locale, and robots.txt loses the `Sitemap:`
|
// the redirect to the default locale, and robots.txt loses the `Sitemap:`
|
||||||
// line pointing crawlers at sitemap.xml. Both routes exist server-side,
|
// line pointing crawlers at sitemap.xml. Both routes exist server-side,
|
||||||
// they just need to be prerendered.
|
// they just need to be prerendered. `/404.html` doesn't match any real
|
||||||
routes: ['/', '/robots.txt'],
|
// route either, so crawling it renders Docus's own error page, which
|
||||||
|
// nginx then serves for every actual 404 instead of its bare default.
|
||||||
|
routes: ['/', '/robots.txt', '/404.html'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// Keeps <NuxtLink> hrefs (including the ones i18n's switchLocalePath builds
|
// Keeps <NuxtLink> hrefs (including the ones i18n's switchLocalePath builds
|
||||||
@@ -259,6 +322,8 @@ export default defineNuxtConfig({
|
|||||||
// and friends, where the words are the same in both languages) are absent on
|
// and friends, where the words are the same in both languages) are absent on
|
||||||
// purpose: a rule there would redirect the page to itself.
|
// purpose: a rule there would redirect the page to itself.
|
||||||
routeRules: {
|
routeRules: {
|
||||||
|
...getSectionIndexRedirects(),
|
||||||
|
|
||||||
// English, previously served at the site root.
|
// English, previously served at the site root.
|
||||||
'/about/welcome': { redirect: { to: '/en/about/welcome/', statusCode: 301 } },
|
'/about/welcome': { redirect: { to: '/en/about/welcome/', statusCode: 301 } },
|
||||||
'/general/networking/nat': { redirect: { to: '/en/general/networking/nat/', statusCode: 301 } },
|
'/general/networking/nat': { redirect: { to: '/en/general/networking/nat/', statusCode: 301 } },
|
||||||
|
|||||||
Generated
+13
@@ -16,6 +16,7 @@
|
|||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"docus": "^5.13.0",
|
"docus": "^5.13.0",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"nuxt": "^4.4.8"
|
"nuxt": "^4.4.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -11449,6 +11450,18 @@
|
|||||||
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
|
"integrity": "sha512-3MOLanc3sb3LNGWQl1RlQlNWURE5g32aUphrDyFeCsxBTk08iE3VNe4CwsUZ0Qs1X+EfX0+r29Sxdpza4B+yRA==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/import-meta-resolve": {
|
||||||
"version": "4.2.0",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
"@nuxtjs/i18n": "^10.6.0",
|
"@nuxtjs/i18n": "^10.6.0",
|
||||||
"better-sqlite3": "^12.11.1",
|
"better-sqlite3": "^12.11.1",
|
||||||
"docus": "^5.13.0",
|
"docus": "^5.13.0",
|
||||||
|
"image-size": "^2.0.2",
|
||||||
"nuxt": "^4.4.8"
|
"nuxt": "^4.4.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user