diff --git a/.github/workflows/validation-and-tests.yml b/.github/workflows/validation-and-tests.yml index 62d462a..97666b3 100755 --- a/.github/workflows/validation-and-tests.yml +++ b/.github/workflows/validation-and-tests.yml @@ -33,6 +33,8 @@ jobs: include: - name: Lint & Format Check command: pnpm test:lint + - name: Redirect Artifact Check + command: pnpm test:redirects - name: Type Check command: pnpm test:types diff --git a/.gitignore b/.gitignore index eff3c8a..72e4cfc 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,11 @@ dist-ssr .env.* !.env.example +# CloudFlare +.cloudflare +_redirects +_redirects.json + # no IDE and LLM/AI settings AGENTS.md CLAUDE.md diff --git a/nuxt.config.ts b/nuxt.config.ts index a82fe4a..c80ac67 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,9 +1,10 @@ import tailwindcss from '@tailwindcss/vite' import { resolveBuildReleaseMetadata } from './server/utils/build-release-metadata' -import { getProjectMetadata } from './shared/utils/project-metadata' +import { buildProjectMetadataRedirectRouteRules, getProjectMetadata } from './shared/utils/project-metadata' const buildReleaseMetadata = resolveBuildReleaseMetadata() const projectMetadata = getProjectMetadata() +const redirectRouteRules = buildProjectMetadataRedirectRouteRules() const seoKeywords = [ projectMetadata.author.name, projectMetadata.author.nickname, @@ -114,22 +115,9 @@ export default defineNuxtConfig({ }, routeRules: { - // Redirect paths. In production (Cloudflare Pages), the `public/_redirects` file handles - // these as proper HTTP 301 at the CDN edge. These routeRules serve as fallback for - // local preview (`nuxt preview`) and as authoritative documentation. - // Keep both in sync. - - // Legal Notice alternative paths - '/imprint': { redirect: { to: projectMetadata.legal.legalNoticePath, statusCode: 301 } }, - '/impressum': { redirect: { to: projectMetadata.legal.legalNoticePath, statusCode: 301 } }, - '/legal': { redirect: { to: projectMetadata.legal.legalNoticePath, statusCode: 301 } }, - - // Privacy Policy alternative paths - '/privacy': { redirect: { to: projectMetadata.legal.privacyPolicyPath, statusCode: 301 } }, - '/datenschutz': { redirect: { to: projectMetadata.legal.privacyPolicyPath, statusCode: 301 } }, - - // Machine-readable metadata alias - '/security.txt': { redirect: { to: '/.well-known/security.txt', statusCode: 301 } }, + // Redirect paths come from `project-metadata.config.ts`. + // The Cloudflare Pages `_redirects` artifact and local `routeRules` fallback share this source. + ...redirectRouteRules, // Build these machine-readable text endpoints as static files for Cloudflare Pages SSG. '/.well-known/security.txt': staticMachineReadableTextRouteRule, diff --git a/package.json b/package.json index b354e55..0ee05ab 100644 --- a/package.json +++ b/package.json @@ -46,7 +46,7 @@ "pnpm": "10.x" }, "scripts": { - "build:ssg": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate", + "build:ssg": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt generate && node scripts/generate-redirects.ts && node scripts/generate-redirects.ts --check", "build:ssr": "cross-env NODE_OPTIONS=--max-old-space-size=8192 nuxt build", "dev": "nuxt dev", "dev:preset": "nuxt dev --port 3000 --public", @@ -59,6 +59,7 @@ "reset": "rimraf node_modules .nuxt .output dist .data", "test": "run-s test:*", "test:lint": "eslint .", + "test:redirects": "node scripts/generate-redirects.ts && node scripts/generate-redirects.ts --check", "test:types": "nuxt typecheck" }, "dependencies": { diff --git a/project-metadata.config.ts b/project-metadata.config.ts index 6aab987..6a3a90f 100644 --- a/project-metadata.config.ts +++ b/project-metadata.config.ts @@ -31,6 +31,7 @@ export interface ProjectMetadata { legalNoticePath: string privacyPolicyPath: string } + redirects: ProjectMetadataRedirectGroup[] projectName: string repository: { licenseUrl: string @@ -47,6 +48,24 @@ export interface ProjectMetadata { siteUrl: string } +export interface ProjectMetadataRedirectEntry { + from: string + statusCode: number + to: string +} + +export interface ProjectMetadataRedirectGroup { + comment: string + entries: ProjectMetadataRedirectEntry[] +} + +export interface ProjectMetadataRedirectRouteRule { + redirect: { + statusCode: number + to: string + } +} + export interface ProjectMetadataSocialEntry { active?: boolean featured: boolean @@ -81,6 +100,10 @@ export interface HydratedProjectMetadata group.entries) +} + +/** Builds the Nuxt `routeRules` redirect subset from project metadata redirects. */ +export function buildProjectMetadataRedirectRouteRules(): Record { + return Object.fromEntries( + getProjectMetadataRedirectEntries().map(entry => [ + entry.from, + { + redirect: { + to: entry.to, + statusCode: entry.statusCode, + }, + }, + ]), + ) +} + +/** Renders the Cloudflare Pages `_redirects` file from project metadata redirects. */ +export function renderProjectMetadataRedirectsFile(): string { + const lines = [ + '# Cloudflare Pages edge-level redirects.', + '# Generated from `project-metadata.config.ts`. Do not edit manually.', + ] + + for (const group of getProjectMetadataRedirectGroups()) { + lines.push('', `# ${group.comment}`) + for (const entry of group.entries) { + lines.push(`${entry.from} ${entry.to} ${entry.statusCode}`) + } + } + + return `${lines.join('\n')}\n` +} + export default projectMetadataConfig diff --git a/public/_redirects b/public/_redirects deleted file mode 100644 index ce76e1d..0000000 --- a/public/_redirects +++ /dev/null @@ -1,14 +0,0 @@ -# Cloudflare Pages edge-level redirects (proper HTTP 301). -# Keep in sync with routeRules in nuxt.config.ts. - -# Legal Notice alternative paths -/imprint /legal-notice 301 -/impressum /legal-notice 301 -/legal /legal-notice 301 - -# Privacy Policy alternative paths -/privacy /privacy-policy 301 -/datenschutz /privacy-policy 301 - -# Machine-readable metadata alias -/security.txt /.well-known/security.txt 301 diff --git a/scripts/generate-redirects.ts b/scripts/generate-redirects.ts new file mode 100644 index 0000000..4d97997 --- /dev/null +++ b/scripts/generate-redirects.ts @@ -0,0 +1,118 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, relative, resolve } from 'node:path' +import { renderProjectMetadataRedirectsFile } from '../project-metadata.config.ts' + +interface GeneratorOptions { + check: boolean + outputPath: string +} + +const DEFAULT_OUTPUT_PATH = '.output/public/_redirects' + +/** + * Parses CLI arguments for redirect artifact generation. + * @param {string[]} argv Command-line arguments after the executable and script path. + * @returns {GeneratorOptions} Parsed generator options. + * @throws {Error} Thrown when an argument is unknown or `--output` has no value. + */ +function parseArgs(argv: string[]): GeneratorOptions { + let check = false + let outputPath = DEFAULT_OUTPUT_PATH + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + + if (argument === '--check') { + check = true + continue + } + + if (argument === '--output') { + const nextArgument = argv[index + 1] + if (!nextArgument) { + throw new Error('Missing value for `--output`.') + } + outputPath = nextArgument + index += 1 + continue + } + + throw new Error(`Unknown argument: ${argument}`) + } + + return { + check, + outputPath, + } +} + +/** + * Verifies that the redirect artifact exists and matches the expected content. + * @param {string} outputPath Absolute path to the generated redirect artifact. + * @param {string} expectedContent Redirect artifact content derived from project metadata. + * @returns {Promise} Resolves when the artifact exists and has no drift. + * @throws {Error} Thrown when the artifact is missing, unreadable, or differs from expected content. + */ +async function assertRedirectsFile(outputPath: string, expectedContent: string): Promise { + let actualContent = '' + + try { + actualContent = await readFile(outputPath, 'utf8') + } + catch (error) { + const relativeOutputPath = relative(process.cwd(), outputPath) + + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + throw new Error( + `Redirect artifact is missing at ${relativeOutputPath}. ` + + 'Run `node scripts/generate-redirects.ts` first.', + { cause: error }, + ) + } + + throw new Error( + `Failed to read redirect artifact at ${relativeOutputPath}.`, + { cause: error }, + ) + } + + if (actualContent !== expectedContent) { + const relativeOutputPath = relative(process.cwd(), outputPath) + throw new Error( + `Redirect artifact drift detected at ${relativeOutputPath}. ` + + 'Regenerate it with `node scripts/generate-redirects.ts`.', + ) + } +} + +/** + * Writes the generated redirect artifact to disk. + * @param {string} outputPath Absolute path where the redirect artifact should be written. + * @param {string} expectedContent Redirect artifact content to persist. + * @returns {Promise} Resolves after the artifact directory exists and the file is written. + */ +async function writeRedirectsFile(outputPath: string, expectedContent: string): Promise { + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, expectedContent, 'utf8') +} + +/** + * Runs the redirect artifact generator in write or check mode. + * @returns {Promise} Resolves after the requested generator action completes. + */ +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)) + const outputPath = resolve(process.cwd(), options.outputPath) + const expectedContent = renderProjectMetadataRedirectsFile() + + if (options.check) { + await assertRedirectsFile(outputPath, expectedContent) + console.log(`Redirect artifact check passed: ${relative(process.cwd(), outputPath)}`) + return + } + + await writeRedirectsFile(outputPath, expectedContent) + console.log(`Redirect artifact written: ${relative(process.cwd(), outputPath)}`) +} + +await main() diff --git a/shared/utils/project-metadata.ts b/shared/utils/project-metadata.ts index 768b785..22b9747 100644 --- a/shared/utils/project-metadata.ts +++ b/shared/utils/project-metadata.ts @@ -10,9 +10,19 @@ const TEL_PREFIX = 'tel:' export type { HydratedProjectMetadata, ProjectMetadata, + ProjectMetadataRedirectEntry, + ProjectMetadataRedirectGroup, + ProjectMetadataRedirectRouteRule, ProjectMetadataSocialEntry, } from '../../project-metadata.config' +export { + buildProjectMetadataRedirectRouteRules, + getProjectMetadataRedirectEntries, + getProjectMetadataRedirectGroups, + renderProjectMetadataRedirectsFile, +} from '../../project-metadata.config' + /** Returns the raw project metadata stored in `project-metadata.config.ts`. */ export function getProjectMetadata(): ProjectMetadata { return projectMetadataConfig