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
2 changes: 2 additions & 0 deletions .github/workflows/validation-and-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ dist-ssr
.env.*
!.env.example

# CloudFlare
.cloudflare
_redirects
_redirects.json

# no IDE and LLM/AI settings
AGENTS.md
CLAUDE.md
Expand Down
22 changes: 5 additions & 17 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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": {
Expand Down
94 changes: 92 additions & 2 deletions project-metadata.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ export interface ProjectMetadata {
legalNoticePath: string
privacyPolicyPath: string
}
redirects: ProjectMetadataRedirectGroup[]
projectName: string
repository: {
licenseUrl: string
Expand All @@ -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
Expand Down Expand Up @@ -81,6 +100,10 @@ export interface HydratedProjectMetadata<TSocial extends ProjectMetadataSocialEn
socials: TSocial[]
}

const LEGAL_NOTICE_PATH = '/legal-notice'
const PRIVACY_POLICY_PATH = '/privacy-policy'
const SECURITY_TXT_PATH = '/.well-known/security.txt'

/**
* Raw config source.
*
Expand Down Expand Up @@ -114,10 +137,35 @@ const projectMetadataConfig = {

/** Legal routes. */
legal: {
legalNoticePath: '/legal-notice',
privacyPolicyPath: '/privacy-policy',
legalNoticePath: LEGAL_NOTICE_PATH,
privacyPolicyPath: PRIVACY_POLICY_PATH,
},

/** Redirect aliases used in route rules and Cloudflare Pages `_redirects`. */
redirects: [
{
comment: 'Legal Notice alternative paths',
entries: [
{ from: '/imprint', to: LEGAL_NOTICE_PATH, statusCode: 301 },
{ from: '/impressum', to: LEGAL_NOTICE_PATH, statusCode: 301 },
{ from: '/legal', to: LEGAL_NOTICE_PATH, statusCode: 301 },
],
},
{
comment: 'Privacy Policy alternative paths',
entries: [
{ from: '/privacy', to: PRIVACY_POLICY_PATH, statusCode: 301 },
{ from: '/datenschutz', to: PRIVACY_POLICY_PATH, statusCode: 301 },
],
},
{
comment: 'Machine-readable metadata alias',
entries: [
{ from: '/security.txt', to: SECURITY_TXT_PATH, statusCode: 301 },
],
},
],

/** Project identity. */
projectName: 'todde.tv',

Expand Down Expand Up @@ -182,4 +230,46 @@ const projectMetadataConfig = {
siteUrl: 'https://todde.tv',
} satisfies ProjectMetadata

/** Returns redirect groups from `project-metadata.config.ts` in declaration order. */
export function getProjectMetadataRedirectGroups(): ProjectMetadataRedirectGroup[] {
return projectMetadataConfig.redirects
}

/** Returns all redirect entries from `project-metadata.config.ts` in declaration order. */
export function getProjectMetadataRedirectEntries(): ProjectMetadataRedirectEntry[] {
return getProjectMetadataRedirectGroups().flatMap(group => group.entries)
}

/** Builds the Nuxt `routeRules` redirect subset from project metadata redirects. */
export function buildProjectMetadataRedirectRouteRules(): Record<string, ProjectMetadataRedirectRouteRule> {
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
14 changes: 0 additions & 14 deletions public/_redirects

This file was deleted.

118 changes: 118 additions & 0 deletions scripts/generate-redirects.ts
Original file line number Diff line number Diff line change
@@ -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<void>} 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<void> {
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 },
)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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<void>} Resolves after the artifact directory exists and the file is written.
*/
async function writeRedirectsFile(outputPath: string, expectedContent: string): Promise<void> {
await mkdir(dirname(outputPath), { recursive: true })
await writeFile(outputPath, expectedContent, 'utf8')
}

/**
* Runs the redirect artifact generator in write or check mode.
* @returns {Promise<void>} Resolves after the requested generator action completes.
*/
async function main(): Promise<void> {
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()
10 changes: 10 additions & 0 deletions shared/utils/project-metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading