Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ea930cd
feat: add reusable author profile fields
toddeTV May 18, 2026
686f6fe
refactor: read site metadata from project config
toddeTV May 18, 2026
cfb2376
refactor: use project config in og images
toddeTV May 18, 2026
0977cc9
refactor: use project config in listing seo copy
toddeTV May 18, 2026
2e2e8e1
refactor: use project config for vcard data
toddeTV May 18, 2026
f51e4ce
feat: add project metadata helper
toddeTV May 18, 2026
c6ec82c
refactor: use project metadata in config
toddeTV May 18, 2026
218e648
refactor: add project metadata composable
toddeTV May 18, 2026
c40523f
refactor: use project metadata in home ui
toddeTV May 18, 2026
6849cc6
refactor: use project metadata in contact exports
toddeTV May 18, 2026
c75cc35
refactor: code beauty
toddeTV May 18, 2026
f99ae67
refactor: rename project metadata helpers
toddeTV May 19, 2026
1bce5a1
refactor: use hydrated project metadata in pages
toddeTV May 19, 2026
6316022
refactor: pass project metadata to vcard builder
toddeTV May 19, 2026
2ea9735
refactor: load project metadata in og components
toddeTV May 19, 2026
96131ca
refactor: remove role summary from project config
toddeTV May 19, 2026
bc2785c
refactor: move project metadata config to typescript
toddeTV May 19, 2026
5467fd1
refactor: rename project metadata config file
toddeTV May 19, 2026
72f5d19
refactor: colocate project metadata types
toddeTV May 19, 2026
fe14c0f
refactor: shorten project metadata config comments
toddeTV May 19, 2026
fd0aa64
docs: rework comments
toddeTV May 19, 2026
e2f9fb4
refactor: centralize remaining project metadata
toddeTV May 19, 2026
ad356e9
refactor: centralize legal page metadata
toddeTV May 19, 2026
5c03f8b
refactor: move legal page data to env vars
toddeTV May 19, 2026
ac04e25
refactor: rename project metadata name fields
toddeTV May 19, 2026
25773e8
fix: escape vcard name fields
toddeTV May 19, 2026
0efa252
docs: document vcard metadata param
toddeTV May 19, 2026
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
16 changes: 11 additions & 5 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,18 @@
CLOUDFLARE_ACCOUNT_ID=
CLOUDFLARE_API_TOKEN=

# Legal page data (address and VAT ID for Impressum / Legal Notice).
# Legal page data
# -> Variables
NUXT_PUBLIC_LEGAL_ADDRESS_STREET=Musterstrasse 1
NUXT_PUBLIC_LEGAL_ADDRESS_CITY=01234 Musterstadt
NUXT_PUBLIC_LEGAL_ADDRESS_COUNTRY=Germany
NUXT_PUBLIC_LEGAL_VAT_ID=DE123456789
NUXT_PUBLIC_LEGAL_NAME=
NUXT_PUBLIC_LEGAL_OCCUPATION=
NUXT_PUBLIC_LEGAL_OCCUPATION_DE=
NUXT_PUBLIC_LEGAL_EMAIL=
NUXT_PUBLIC_LEGAL_PHONE_DISPLAY=
NUXT_PUBLIC_LEGAL_PHONE_URI=
NUXT_PUBLIC_LEGAL_ADDRESS_STREET=
NUXT_PUBLIC_LEGAL_ADDRESS_CITY=
NUXT_PUBLIC_LEGAL_ADDRESS_COUNTRY=
NUXT_PUBLIC_LEGAL_VAT_ID=

# Nuxt OG Image Generation Secret (used for signing OG image URLs).
# -> Secrets
Expand Down
18 changes: 6 additions & 12 deletions app/app.vue
Original file line number Diff line number Diff line change
@@ -1,20 +1,14 @@
<script setup lang="ts">
/**
* Load social links from the `socials` content collection and injects them into the Schema.org Person identity
* via `sameAs` and `email`.
* Loads hydrated project metadata and injects the derived Schema.org identity fields.
*/
const { data: socials } = await useAsyncData('app-socials-all', () =>
queryCollection('socials').where('active', '=', true).order('sortOrder', 'ASC').all(),
)
if (socials.value?.length) {
const emailEntry = socials.value.find(s => s.url.startsWith('mailto:'))
const { data: projectMetadata } = await useProjectMetadata()

if (projectMetadata.value) {
useSchemaOrg([
definePerson({
// sameAs expects profile URLs; exclude mailto: and tel: schemes.
sameAs: socials.value
.filter(s => !s.url.startsWith('mailto:') && !s.url.startsWith('tel:'))
.map(s => s.url),
...(emailEntry ? { email: emailEntry.url.replace('mailto:', '') } : {}),
sameAs: projectMetadata.value.author.sameAs,
...(projectMetadata.value.author.contact ? { email: projectMetadata.value.author.contact } : {}),
}),
])
}
Expand Down
6 changes: 4 additions & 2 deletions app/components/OgImageComponents/OgImageFooter.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
const projectMetadata = getProjectMetadata()

/**
* Shared footer for all OG image components.
* Renders author name (left), site URL (right), and a bottom gradient accent line.
Expand Down Expand Up @@ -30,7 +32,7 @@
color: '#00dc82',
}"
>
Thorsten Seyschab
{{ projectMetadata.author.name }}
</span>
<span
:style="{
Expand All @@ -39,7 +41,7 @@
color: '#71717a',
}"
>
todde.tv
{{ projectMetadata.projectName }}
</span>
</div>

Expand Down
24 changes: 13 additions & 11 deletions app/components/OgImageTemplate/OgImageDefault.takumi.vue
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
<script setup lang="ts">
/**
* Default/fallback OG image component for generic pages.
* Used by [...slug].vue when no specific OG component is configured.
*/
withDefaults(defineProps<{
const props = defineProps<{
/** Page title override. */
title?: string
/** Page description override. */
description?: string
}>(), {
title: 'todde.tv',
description: 'IT consultant, senior full-stack developer, and conference speaker.',
})
}>()

const projectMetadata = getProjectMetadata()
const resolvedTitle = computed(() => props.title ?? projectMetadata.projectName)
const resolvedDescription = computed(() => props.description ?? projectMetadata.author.role)

/**
* Default/fallback OG image component for generic pages.
* Used by [...slug].vue when no specific OG component is configured.
*/
</script>

<template>
Expand Down Expand Up @@ -42,8 +44,8 @@ withDefaults(defineProps<{
boxSizing: 'border-box',
}"
>
<OgImageTitle size="md" :text="title" />
<OgImageDescription v-if="description" size="lg" :text="description" />
<OgImageTitle size="md" :text="resolvedTitle" />
<OgImageDescription v-if="resolvedDescription" size="lg" :text="resolvedDescription" />
</div>

<OgImageFooter />
Expand Down
22 changes: 12 additions & 10 deletions app/components/OgImageTemplate/OgImageHome.takumi.vue
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
<script setup lang="ts">
/**
* Default OG image component for the homepage.
*/
withDefaults(defineProps<{
const props = defineProps<{
/** Page title override. */
title?: string
/** Page description override. */
description?: string
}>(), {
title: 'todde.tv',
description: 'IT consultant, senior full-stack developer, and conference speaker.',
})
}>()

const projectMetadata = getProjectMetadata()
const resolvedTitle = computed(() => props.title ?? projectMetadata.projectName)
const resolvedDescription = computed(() => props.description ?? projectMetadata.author.role)

/**
* Default OG image component for the homepage.
*/
</script>

<template>
Expand Down Expand Up @@ -40,8 +42,8 @@ withDefaults(defineProps<{
boxSizing: 'border-box',
}"
>
<OgImageTitle :text="title" />
<OgImageDescription size="lg" :text="description" />
<OgImageTitle :text="resolvedTitle" />
<OgImageDescription size="lg" :text="resolvedDescription" />
</div>

<div
Expand Down
14 changes: 14 additions & 0 deletions app/components/content/LegalAuthorName.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<script setup lang="ts">
/** Renders the legal name from runtimeConfig. */

const config = useRuntimeConfig()

const legalName = computed(() => config.public.legalName as string)
</script>

<template>
<span v-if="legalName">{{ legalName }}</span>
<span v-else class="text-text-dim italic">
[Legal name not configured - set NUXT_PUBLIC_LEGAL_NAME]
</span>
</template>
37 changes: 17 additions & 20 deletions app/components/content/LegalContactInfo.vue
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
<script setup lang="ts">
/**
* Renders contact info (email or phone) by querying the socials collection.
* Finds the social entry with the lowest sortOrder whose URL starts with
* `mailto:` (for email) or `tel:` (for phone) and displays its handle.
* Renders legal contact info from runtimeConfig.
* Used as inline MDC component in legal content pages.
*/

Expand All @@ -11,30 +9,29 @@ const props = defineProps<{
type: 'email' | 'phone'
}>()

const urlPrefix = computed(() => (props.type === 'email' ? 'mailto:' : 'tel:'))
const config = useRuntimeConfig()

const { data: social } = await useAsyncData(
`legal-contact-info-${props.type}`,
() =>
queryCollection('socials')
.where('active', '=', true)
.order('sortOrder', 'ASC')
.all(),
{
transform: socials =>
socials.find(s => s.url.startsWith(urlPrefix.value)) ?? null,
},
)
const label = computed(() => {
return props.type === 'email'
? config.public.legalEmail as string
: config.public.legalPhoneDisplay as string
})

const url = computed(() => {
return props.type === 'email'
? `mailto:${config.public.legalEmail as string}`
: config.public.legalPhoneUri as string
})
</script>

<template>
<NuxtLink
v-if="social"
:href="social.url"
v-if="label && url"
:to="url"
>
{{ social.handle }}
{{ label }}
</NuxtLink>
<span v-else class="text-text-dim italic">
[{{ type === 'email' ? 'Email' : 'Phone' }} not configured - add a matching social entry]
[{{ type === 'email' ? 'Email' : 'Phone' }} not configured - set matching legal env vars]
</span>
</template>
20 changes: 20 additions & 0 deletions app/components/content/LegalOccupation.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<script setup lang="ts">
/** Renders the legal occupation from runtimeConfig. */

const config = useRuntimeConfig()

const occupation = computed(() => config.public.legalOccupation as string)
const occupationDe = computed(() => config.public.legalOccupationDe as string)
</script>

<template>
<span v-if="occupation">
{{ occupation }}
<template v-if="occupationDe">
(<em>{{ occupationDe }}</em>)
</template>
</span>
<span v-else class="text-text-dim italic">
[Legal occupation not configured - set NUXT_PUBLIC_LEGAL_OCCUPATION]
</span>
</template>
11 changes: 11 additions & 0 deletions app/components/content/LegalRepositoryLink.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<script setup lang="ts">
/** Renders the canonical repository link from centralized project metadata. */

const projectMetadata = getProjectMetadata()
</script>

<template>
<NuxtLink target="_blank" :to="projectMetadata.repository.url">
GitHub
</NuxtLink>
</template>
24 changes: 11 additions & 13 deletions app/components/layout/SiteFooter.vue
Original file line number Diff line number Diff line change
@@ -1,19 +1,17 @@
<script setup lang="ts">
const { data: socials } = await useAsyncData('socials-only-featured', () =>
queryCollection('socials')
.where('active', '=', true)
.where('featured', '=', true)
.order('sortOrder', 'ASC')
.all(),
)
const { data: projectMetadata } = await useProjectMetadata()

const START_YEAR = 2026
const REPOSITORY_URL = 'https://github.com/toddeTV/todde.tv'

const today = useTodayDate()
const currentYear = computed(() => Number(today.value.slice(0, 4)))
const runtimeConfig = useRuntimeConfig()
const releaseLabel = runtimeConfig.public.build.releaseLabel
const featuredSocials = computed(() => projectMetadata.value?.featuredSocials ?? [])
const authorName = computed(() => projectMetadata.value?.author.name ?? '')
const legalNoticePath = computed(() => projectMetadata.value?.legal.legalNoticePath ?? '/legal-notice')
const privacyPolicyPath = computed(() => projectMetadata.value?.legal.privacyPolicyPath ?? '/privacy-policy')
const repositoryUrl = computed(() => projectMetadata.value?.repository.url ?? '')

/** Returns "2026" if current year equals start year, otherwise "2026-{currentYear}". */
const yearSpan = computed(() =>
Expand All @@ -27,7 +25,7 @@ const yearSpan = computed(() =>
<AppContainer class="flex flex-col items-center gap-4 py-8">
<div class="flex items-center gap-4">
<NuxtLink
v-for="social in socials"
v-for="social in featuredSocials"
:key="social.url"
:aria-label="social.name"
class="flex items-center"
Expand All @@ -47,14 +45,14 @@ const yearSpan = computed(() =>

<p class="text-center text-xs text-text-dim">
Created with <Icon class="inline-block" name="ph:heart" :size="12" /> by
Thorsten Seyschab, &copy; {{ yearSpan }}, All Rights Reserved.
{{ authorName }}, &copy; {{ yearSpan }}, All Rights Reserved.
</p>

<p class="flex flex-wrap items-center justify-center gap-y-1 text-center text-xs text-text-dim">
<span class="inline-flex items-center whitespace-nowrap">
<!-- `link-checker/valid-sitemap-link` does not resolve content-backed catch-all pages here, so we need: -->
<!-- eslint-disable-next-line link-checker/valid-sitemap-link -->
<NuxtLink class="text-xs text-text-dim hover:text-text" to="/legal-notice">
<NuxtLink class="text-xs text-text-dim hover:text-text" :to="legalNoticePath">
Legal Notice
</NuxtLink>

Expand All @@ -64,7 +62,7 @@ const yearSpan = computed(() =>
<span class="inline-flex items-center whitespace-nowrap">
<!-- `link-checker/valid-sitemap-link` does not resolve content-backed catch-all pages here, so we need: -->
<!-- eslint-disable-next-line link-checker/valid-sitemap-link -->
<NuxtLink class="text-xs text-text-dim hover:text-text" to="/privacy-policy">
<NuxtLink class="text-xs text-text-dim hover:text-text" :to="privacyPolicyPath">
Privacy Policy
</NuxtLink>

Expand All @@ -78,7 +76,7 @@ const yearSpan = computed(() =>
aria-label="GitHub repository"
class="relative -top-px ml-1.5 inline-flex items-center align-middle text-xs"
target="_blank"
:to="REPOSITORY_URL"
:to="repositoryUrl"
>
<Icon name="simple-icons:github" :size="14" />
</NuxtLink>
Expand Down
27 changes: 27 additions & 0 deletions app/composables/useProjectMetadata.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { SocialsCollectionItem } from '@nuxt/content'

/** Loads hydrated project metadata, including socials-derived fields, for app runtime code. */
export function useProjectMetadata() {
const projectMetadataState = useState<HydratedProjectMetadata<SocialsCollectionItem> | null>(
'project-metadata-state',
() => null,
)

const asyncData = useAsyncData('project-metadata', async () => {
if (projectMetadataState.value) {
return projectMetadataState.value
}

const socials = await queryCollection('socials').all()

return prepareProjectMetadata(socials)
})

watchEffect(() => {
if (asyncData.data.value) {
projectMetadataState.value = asyncData.data.value
}
})

return asyncData
}
Loading
Loading