Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/ci-cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
62 changes: 62 additions & 0 deletions app/pages/404.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
definePageMeta({
name: '404'
})

useSeoMeta({
title: 'Page not found',
description: 'The page you are looking for does not exist.',
ogTitle: 'Page not found',
ogDescription: 'The page you are looking for does not exist.',
robots: 'noindex, nofollow'
})

defineOgImage('NuxtSeoSatori')
</script>

<template>
<UPage>
<UPageHero
title="Page not found"
description="The page you are looking for does not exist or has been moved."
:ui="{
title: '!mx-0 max-w-3xl text-left text-3xl font-bold tracking-tight text-highlighted sm:text-4xl lg:text-5xl',
description: '!mx-0 max-w-2xl text-left',
links: 'justify-start'
}"
>
<template #links>
<nav aria-label="Suggested destinations">
<ul class="flex flex-wrap items-center gap-3 list-none p-0">
<li>
<UButton
to="/"
icon="i-lucide-home"
color="neutral"
label="Back to home"
/>
</li>
<li>
<UButton
to="/labs"
icon="i-lucide-folder"
color="neutral"
variant="outline"
label="Browse labs"
/>
</li>
<li>
<UButton
to="/speaking"
icon="i-lucide-mic"
color="neutral"
variant="outline"
label="See speaking"
/>
</li>
</ul>
</nav>
</template>
</UPageHero>
</UPage>
</template>
62 changes: 62 additions & 0 deletions app/pages/410.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
definePageMeta({
name: '410'
})

useSeoMeta({
title: 'Page permanently removed',
description: 'This page has been permanently removed and is no longer available.',
ogTitle: 'Page permanently removed',
ogDescription: 'This page has been permanently removed and is no longer available.',
robots: 'noindex, nofollow'
})

defineOgImage('NuxtSeoSatori')
</script>

<template>
<UPage>
<UPageHero
title="This page is gone"
description="The page you are looking for has been permanently removed and will not return. Try one of the destinations below."
:ui="{
title: '!mx-0 max-w-3xl text-left text-3xl font-bold tracking-tight text-highlighted sm:text-4xl lg:text-5xl',
description: '!mx-0 max-w-2xl text-left',
links: 'justify-start'
}"
>
<template #links>
<nav aria-label="Suggested destinations">
<ul class="flex flex-wrap items-center gap-3 list-none p-0">
<li>
<UButton
to="/"
icon="i-lucide-home"
color="neutral"
label="Back to home"
/>
</li>
<li>
<UButton
to="/labs"
icon="i-lucide-folder"
color="neutral"
variant="outline"
label="Browse labs"
/>
</li>
<li>
<UButton
to="/speaking"
icon="i-lucide-mic"
color="neutral"
variant="outline"
label="See speaking"
/>
</li>
</ul>
</nav>
</template>
</UPageHero>
</UPage>
</template>
58 changes: 56 additions & 2 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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-<n>/) 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: {
Expand All @@ -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'
Expand All @@ -75,6 +128,7 @@ export default defineNuxtConfig({

sitemap: {
autoLastmod: true,
xsl: false
xsl: false,
exclude: ['/404', '/410']
}
})
11 changes: 0 additions & 11 deletions public/.htaccess

This file was deleted.

30 changes: 30 additions & 0 deletions public/.htaccess.tpl
Original file line number Diff line number Diff line change
@@ -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-<n>/) builds.

# HTTP security headers (FIND-01 of security-audit)
# Apache mod_headers must be enabled on the host.

<IfModule mod_headers.c>
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';"
</IfModule>

# 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.
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase __BASE__
RewriteRule ^tags(/.*)?$ - [G,L,NC]
RewriteRule ^en(/.*)?$ - [G,L,NC]
RewriteRule ^legal/?$ - [G,L,NC]
</IfModule>