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
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..c08b930 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: {
@@ -49,6 +77,31 @@ export default defineNuxtConfig({
}
},
+ icon: {
+ // 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)
+ // 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: {
+ globInclude: ['**/*.{vue,jsx,tsx,ts,mts,md,mdc,mdx,yml,yaml}']
+ },
+ icons: [
+ 'lucide:sun',
+ 'lucide:moon',
+ 'lucide:arrow-right',
+ 'lucide:arrow-up-right',
+ 'lucide:chevron-down'
+ ]
+ },
+ fallbackToApi: 'server-only'
+ },
+
robots: {
disallow: [],
sitemap: '/sitemap.xml'
@@ -75,6 +128,7 @@ export default defineNuxtConfig({
sitemap: {
autoLastmod: true,
- xsl: false
+ xsl: false,
+ exclude: ['/404', '/410']
}
})
diff --git a/public/.htaccess b/public/.htaccess
deleted file mode 100644
index 8c9638f..0000000
--- a/public/.htaccess
+++ /dev/null
@@ -1,11 +0,0 @@
-# HTTP security headers (FIND-01 of security-audit)
-# Apache mod_headers must be enabled on the host.
-
-
- Header always set X-Frame-Options "DENY"
- Header always set X-Content-Type-Options "nosniff"
- 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';"
-
diff --git a/public/.htaccess.tpl b/public/.htaccess.tpl
new file mode 100644
index 0000000..408d2e6
--- /dev/null
+++ b/public/.htaccess.tpl
@@ -0,0 +1,30 @@
+# 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.
+
+
+ Header always set X-Frame-Options "DENY"
+ Header always set X-Content-Type-Options "nosniff"
+ 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' '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).
+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]
+