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
25 changes: 19 additions & 6 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
import tailwindcss from '@tailwindcss/vite'
import projectConfig from './project.config.json'
import { resolveBuildReleaseMetadata } from './server/utils/build-release-metadata'

const buildReleaseMetadata = resolveBuildReleaseMetadata()
const staticMachineReadableTextRouteRule = {
prerender: true,
headers: {
'cache-control': 'public, max-age=86400, s-maxage=604800',
},
} as const

export default defineNuxtConfig({
modules: [
Expand Down Expand Up @@ -32,8 +39,8 @@ export default defineNuxtConfig({
// titleTemplate, description, og:site_name, and htmlAttrs.lang are handled by `@nuxtjs/seo` via `site`
// config - do not duplicate here.
meta: [
{ name: 'application-name', content: 'todde.tv' },
{ name: 'author', content: 'Thorsten Seyschab' },
{ name: 'application-name', content: projectConfig.projectName },
{ name: 'author', content: projectConfig.author.name },

// Ignored by Google since 2009, but some minor search engines (Yandex, Baidu) still
// consider it. Harmless to include.
Expand Down Expand Up @@ -97,11 +104,10 @@ export default defineNuxtConfig({
],

site: { // for `@nuxtjs/seo` - shared site config used by all SEO sub-modules (like `ogImage`, `schemaOrg`, etc.)
url: 'https://todde.tv',
url: projectConfig.siteUrl,
// name: 'todde.tv',
name: 'Thorsten Seyschab',
description:
'Personal portfolio of Thorsten Seyschab - IT consultant, senior full-stack developer, and conference speaker.',
name: projectConfig.author.name,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
description: projectConfig.siteDescription,
defaultLocale: 'en',
},

Expand Down Expand Up @@ -138,6 +144,13 @@ export default defineNuxtConfig({
// Privacy Policy alternative paths
'/privacy': { redirect: { to: '/privacy-policy', statusCode: 301 } },
'/datenschutz': { redirect: { to: '/privacy-policy', statusCode: 301 } },

// Machine-readable metadata alias
'/security.txt': { redirect: { to: '/.well-known/security.txt', statusCode: 301 } },

// Build these machine-readable text endpoints as static files for Cloudflare Pages SSG.
'/.well-known/security.txt': staticMachineReadableTextRouteRule,
'/humans.txt': staticMachineReadableTextRouteRule,
},

compatibilityDate: '2026-03-04',
Expand Down
33 changes: 33 additions & 0 deletions project.config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"author": {
"contact": "hello@todde.tv",
"location": "Dresden, Germany",
"name": "Thorsten Seyschab",
"role": "IT consultant, senior full-stack developer, and conference speaker",
"sameAs": [
"https://github.com/toddeTV/",
"https://x.com/toddeTV",
"https://bsky.app/profile/todde.tv",
"https://www.linkedin.com/in/toddetv/"
],
"url": "https://todde.tv"
},
"legal": {
"legalNoticePath": "/legal-notice",
"privacyPolicyPath": "/privacy-policy"
},
"projectName": "todde.tv",
"repository": {
"licenseUrl": "https://github.com/toddeTV/todde.tv/blob/main/LICENSE.md",
"url": "https://github.com/toddeTV/todde.tv"
},
"security": {
"contact": "mailto:hello@todde.tv",
"preferredLanguages": [
"en",
"de"
]
},
"siteDescription": "Personal portfolio of Thorsten Seyschab - IT consultant, senior full-stack developer, and conference speaker.",
"siteUrl": "https://todde.tv"
}
3 changes: 3 additions & 0 deletions public/_redirects
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,6 @@
# Privacy Policy alternative paths
/privacy /privacy-policy 301
/datenschutz /privacy-policy 301

# Machine-readable metadata alias
/security.txt /.well-known/security.txt 301
52 changes: 52 additions & 0 deletions server/routes/.well-known/security.txt.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import projectConfig from '~~/project.config.json'

const defaultSecurityTxtLifetimeDays = 180

interface SecurityTxtContentOptions {
canonicalUrl: string
contact: string
policyUrl?: string
preferredLanguages: string[]
}

/** Returns the ISO 8601 expiration timestamp for `security.txt`. */
function createSecurityTxtExpires(
now: Date = new Date(),
lifetimeDays: number = defaultSecurityTxtLifetimeDays,
): string {
const expiresAt = new Date(now.getTime())

expiresAt.setUTCDate(expiresAt.getUTCDate() + lifetimeDays)

return expiresAt.toISOString()
}

/** Builds the plain text `security.txt` response body from route metadata. */
function buildSecurityTxtContent(
options: SecurityTxtContentOptions,
now: Date = new Date(),
): string {
const lines = [
`Contact: ${options.contact}`,
`Expires: ${createSecurityTxtExpires(now)}`,
`Canonical: ${options.canonicalUrl}`,
`Preferred-Languages: ${options.preferredLanguages.join(', ')}`,
]

if (options.policyUrl) {
lines.push(`Policy: ${options.policyUrl}`)
}

return `${lines.join('\n')}\n`
}

export default defineEventHandler((event) => {
const content = buildSecurityTxtContent({
canonicalUrl: new URL('/.well-known/security.txt', projectConfig.siteUrl).toString(),
contact: projectConfig.security.contact,
preferredLanguages: projectConfig.security.preferredLanguages,
})

setResponseHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
return content
})
36 changes: 36 additions & 0 deletions server/routes/humans.txt.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import projectConfig from '~~/project.config.json'

/** Returns the plain text `humans.txt` response using `projectConfig`, `useRuntimeConfig`, and `setResponseHeader`. */
export default defineEventHandler((event) => {
const { author, legal, projectName, repository, siteDescription, siteUrl } = projectConfig
const legalNoticeUrl = new URL(legal.legalNoticePath, siteUrl).toString()
const privacyPolicyUrl = new URL(legal.privacyPolicyPath, siteUrl).toString()
const runtimeConfig = useRuntimeConfig(event)
const authorProfiles = author.sameAs.map(profileUrl => ` Profile: ${profileUrl}`)

const content = [
'# humanstxt.org/',
'',
'/* PROJECT */',
` Name: ${projectName}`,
` Version: ${runtimeConfig.public.build.releaseLabel}`,
` URL: ${siteUrl}`,
` Description: ${siteDescription}`,
` Legal Notice: ${legalNoticeUrl}`,
` Privacy Policy: ${privacyPolicyUrl}`,
'',
'/* AUTHOR */',
` Name: ${author.name}`,
` Role: ${author.role}`,
` Contact: ${author.contact}`,
` Website: ${author.url}`,
...authorProfiles,
'',
'/* SOURCE CODE */',
` Repository: ${repository.url}`,
` License: ${repository.licenseUrl}`,
].join('\n')

setResponseHeader(event, 'Content-Type', 'text/plain; charset=utf-8')
return `${content}\n`
})
Loading