From 5ed565fb98aa2917d7a4473687531b2c61725f2d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:07:00 +0000 Subject: [PATCH 1/6] feat: add custom 404/410 pages and retire legacy URL ranges Adds two prerendered error pages (`/404` and `/410`) wrapped by the default layout so missing and removed URLs get the site's actual chrome and copy instead of Apache's defaults. `/tags*`, `/en*`, and `/legal` are rewritten to 410 Gone so Google de-indexes them faster than 404. `.htaccess` is now templated from `public/.htaccess.tpl` at generate time so the `ErrorDocument` and `RewriteBase` paths resolve correctly for both production (/) and preview (/pr-/) builds. Closes #46. --- app/pages/404.vue | 62 +++++++++++++++++++++++++++++ app/pages/410.vue | 62 +++++++++++++++++++++++++++++ nuxt.config.ts | 33 ++++++++++++++- public/{.htaccess => .htaccess.tpl} | 19 +++++++++ 4 files changed, 174 insertions(+), 2 deletions(-) create mode 100644 app/pages/404.vue create mode 100644 app/pages/410.vue rename public/{.htaccess => .htaccess.tpl} (51%) diff --git a/app/pages/404.vue b/app/pages/404.vue new file mode 100644 index 0000000..838fa12 --- /dev/null +++ b/app/pages/404.vue @@ -0,0 +1,62 @@ + + + diff --git a/app/pages/410.vue b/app/pages/410.vue new file mode 100644 index 0000000..3b63b14 --- /dev/null +++ b/app/pages/410.vue @@ -0,0 +1,62 @@ + + + diff --git a/nuxt.config.ts b/nuxt.config.ts index 332d840..663cb81 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -34,12 +34,40 @@ export default defineNuxtConfig({ nitro: { prerender: { routes: [ - '/' + '/', + '/404', + '/410' ], crawlLinks: true } }, + hooks: { + 'nitro:init'(nitro) { + // Resolve __BASE__ in public/.htaccess.tpl using the runtime base URL + // and write the result to .output/public/.htaccess. Templating happens at + // generate time so production (/) and preview (/pr-/) builds each get + // the correct ErrorDocument and RewriteBase paths. + if (nitro.options.dev) return + nitro.hooks.hook('close', async () => { + const { promises: fs } = await import('node:fs') + const { resolve } = await import('node:path') + const baseURL = nitro.options.runtimeConfig.app.baseURL || '/' + const templatePath = resolve(nitro.options.rootDir, 'public/.htaccess.tpl') + const outputDir = nitro.options.output.publicDir + const outputPath = resolve(outputDir, '.htaccess') + const leakedTemplate = resolve(outputDir, '.htaccess.tpl') + try { + const tpl = await fs.readFile(templatePath, 'utf8') + await fs.writeFile(outputPath, tpl.replaceAll('__BASE__', baseURL)) + await fs.rm(leakedTemplate, { force: true }) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + }) + } + }, + eslint: { config: { stylistic: { @@ -75,6 +103,7 @@ export default defineNuxtConfig({ sitemap: { autoLastmod: true, - xsl: false + xsl: false, + exclude: ['/404', '/410'] } }) diff --git a/public/.htaccess b/public/.htaccess.tpl similarity index 51% rename from public/.htaccess rename to public/.htaccess.tpl index 8c9638f..9b361a2 100644 --- a/public/.htaccess +++ b/public/.htaccess.tpl @@ -1,3 +1,7 @@ +# Generated by Nitro from public/.htaccess.tpl — __BASE__ is replaced with the +# app base URL at generate time so paths resolve correctly for both production +# (/) and preview (/pr-/) builds. + # HTTP security headers (FIND-01 of security-audit) # Apache mod_headers must be enabled on the host. @@ -9,3 +13,18 @@ Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" + +# Custom error pages (prerendered Nuxt routes). +ErrorDocument 404 __BASE__404/index.html +ErrorDocument 410 __BASE__410/index.html + +# Permanently removed URL ranges — return 410 Gone so search engines drop them +# from their index faster than they would for 404. The body is served from the +# ErrorDocument 410 above. + + RewriteEngine On + RewriteBase __BASE__ + RewriteRule ^tags(/.*)?$ - [G,L,NC] + RewriteRule ^en(/.*)?$ - [G,L,NC] + RewriteRule ^legal/?$ - [G,L,NC] + From aefd5929d1562b1533ec63f54a3ff1d65d351fca Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:30:21 +0000 Subject: [PATCH 2/6] ci: include hidden files in site artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit upload-artifact strips dotfiles by default, so .htaccess never reached the deploy step. That left both the security headers (added in #20) and the new 404/410 ErrorDocument rules missing in production and preview — Apache fell back to the host's default error page. --- .github/workflows/ci-cd.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 506432c..db2ead2 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -117,6 +117,10 @@ jobs: with: name: site-output path: .output/public + # Required so .htaccess (and any other dotfile in the output) is + # packed into the artifact — upload-artifact strips hidden files + # by default. + include-hidden-files: true retention-days: 1 compression-level: 0 if-no-files-found: error From 69a7f89c18ac96ec89687c83e37f21e5fe088290 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:38:42 +0000 Subject: [PATCH 3/6] fix(icon): bundle theme-toggle icons and disable client API fallback ColorModeButton builds icon names dynamically (`i-lucide-${...}`), so the build-time scanner can't see them and they fell back to `api.iconify.design` at runtime. CSP `connect-src 'self'` (now actually applied since the .htaccess deploys) blocked the request. List sun/moon explicitly in the client bundle and set `fallbackToApi: "server-only"` so the runtime never reaches out to the public Iconify CDN. --- nuxt.config.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/nuxt.config.ts b/nuxt.config.ts index 663cb81..a8657f9 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -77,6 +77,21 @@ export default defineNuxtConfig({ } }, + icon: { + // ColorModeButton builds icon names dynamically (`i-lucide-${...}`), so + // the build-time scanner can't pick them up — list them explicitly here. + // `fallbackToApi: 'server-only'` keeps SSR/generate-time resolution but + // blocks the runtime `api.iconify.design` request that CSP `connect-src + // 'self'` would otherwise reject. + clientBundle: { + icons: [ + 'lucide:sun', + 'lucide:moon' + ] + }, + fallbackToApi: 'server-only' + }, + robots: { disallow: [], sitemap: '/sitemap.xml' From 96ea59efbd2083485f8fa17852398dbdb55e1f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:44:01 +0000 Subject: [PATCH 4/6] fix(csp): allow wasm-unsafe-eval for Nuxt Content sqlite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client-side navigation through Nuxt Content v3 instantiates a WebAssembly-compiled sqlite for in-browser queries. The strict `script-src 'self' 'unsafe-inline'` blocked `WebAssembly.instantiate`, breaking navigation away from the 404 page. `'wasm-unsafe-eval'` is the dedicated CSP source for exactly this case — it permits WebAssembly compilation without enabling general JavaScript `eval()`. --- public/.htaccess.tpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/.htaccess.tpl b/public/.htaccess.tpl index 9b361a2..408d2e6 100644 --- a/public/.htaccess.tpl +++ b/public/.htaccess.tpl @@ -11,7 +11,7 @@ Header always set Referrer-Policy "strict-origin-when-cross-origin" Header always set Permissions-Policy "camera=(), microphone=(), geolocation=()" Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" - Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" + Header always set Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" # Custom error pages (prerendered Nuxt routes). From 43a6d8241af9f426480bb89d9fbc1e9240a89fa4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 15:52:50 +0000 Subject: [PATCH 5/6] fix(icon): scan content YAML + bundle @nuxt/ui internals The scanner is off by default, so only the two explicitly-listed icons were bundled. After navigating from /404 to home, every dynamically referenced icon (those bound via `:icon="page.icon"` from YAML, plus the chevrons and arrows used inside @nuxt/ui components) tried to fetch from `api.iconify.design` and got blocked by CSP. Turn on `clientBundle.scan` so the scanner picks up icon strings in .vue and .yml files, and explicitly list the @nuxt/ui internals (chevron-down, arrow-right, arrow-up-right) since those live in node_modules and the scanner skips them. Result: 21 icons inlined, 6 KB uncompressed. --- nuxt.config.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index a8657f9..8016d7e 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -78,15 +78,22 @@ export default defineNuxtConfig({ }, icon: { - // ColorModeButton builds icon names dynamically (`i-lucide-${...}`), so - // the build-time scanner can't pick them up — list them explicitly here. + // The build-time scanner only sees static literals. We need to cover: + // - dynamic names in templates (ColorModeButton builds `i-lucide-${...}`) + // - icons referenced inside @nuxt/ui (lives in node_modules, not scanned) + // YAML content under `content/` is picked up by `scan: true` (the + // scanner's default globs include .yml / .yaml). // `fallbackToApi: 'server-only'` keeps SSR/generate-time resolution but // blocks the runtime `api.iconify.design` request that CSP `connect-src // 'self'` would otherwise reject. clientBundle: { + scan: true, icons: [ 'lucide:sun', - 'lucide:moon' + 'lucide:moon', + 'lucide:arrow-right', + 'lucide:arrow-up-right', + 'lucide:chevron-down' ] }, fallbackToApi: 'server-only' From ae44818e28f6b77d132307bc8ec7398cfcf500eb Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 16 May 2026 16:02:03 +0000 Subject: [PATCH 6/6] fix(icon): include .ts files in client-bundle scanner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner's default globs cover .vue / .yml / .md etc. but not .ts, so icon references in app/utils/* and app.config.ts (labStatusIconMap 'sparkles' / 'pause', toast icons, footer links, nav links) were missed and fell back to api.iconify.design at runtime. Extend globInclude to add .ts and .mts. Bundle now resolves 27 icons inline (8 KB) — every literal icon reference in the project. --- nuxt.config.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/nuxt.config.ts b/nuxt.config.ts index 8016d7e..c08b930 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -81,13 +81,16 @@ export default defineNuxtConfig({ // The build-time scanner only sees static literals. We need to cover: // - dynamic names in templates (ColorModeButton builds `i-lucide-${...}`) // - icons referenced inside @nuxt/ui (lives in node_modules, not scanned) - // YAML content under `content/` is picked up by `scan: true` (the - // scanner's default globs include .yml / .yaml). + // The default scanner globs cover .vue / .yml / .md etc., but not .ts — + // and several status / nav icons (labs.ts, links.ts, app.config.ts) live + // there. Extend the globs to include .ts (and .mts for safety). // `fallbackToApi: 'server-only'` keeps SSR/generate-time resolution but // blocks the runtime `api.iconify.design` request that CSP `connect-src // 'self'` would otherwise reject. clientBundle: { - scan: true, + scan: { + globInclude: ['**/*.{vue,jsx,tsx,ts,mts,md,mdc,mdx,yml,yaml}'] + }, icons: [ 'lucide:sun', 'lucide:moon',