diff --git a/AGENTS.md b/AGENTS.md
index eb1e31c..9e93239 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -143,7 +143,7 @@ repoUrl: https://... # optional GitHub repo URL
note: ... # optional note shown on detail page
```
-The slug used for the URL is derived from the filename (e.g., `my-project.yml` → `/labs/my-project`).
+The slug used for the URL is derived from the filename (e.g., `my-project.yml` → `/labs/my-project/`).
### Adding a talk entry
diff --git a/CLAUDE.md b/CLAUDE.md
index 72884dc..dc18419 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -145,7 +145,7 @@ repoUrl: https://... # optional GitHub repo URL
note: ... # optional note shown on detail page
```
-The slug used for the URL is derived from the filename (e.g., `my-project.yml` → `/labs/my-project`).
+The slug used for the URL is derived from the filename (e.g., `my-project.yml` → `/labs/my-project/`).
### Adding a talk entry
diff --git a/app/app.vue b/app/app.vue
index ca58635..a4b5ab3 100644
--- a/app/app.vue
+++ b/app/app.vue
@@ -5,8 +5,12 @@ const baseURL = runtimeConfig.app.baseURL
const basePrefix = baseURL.replace(/\/$/, '')
+// Canonical URLs use the trailing-slash form (see `site.trailingSlash` in
+// nuxt.config.ts); normalise here so a slash-less route never leaks through.
const canonicalUrl = computed(() => {
- return new URL(`${basePrefix}${route.path || '/'}`, runtimeConfig.public.siteUrl).toString()
+ const path = route.path || '/'
+ const normalised = path.endsWith('/') ? path : `${path}/`
+ return new URL(`${basePrefix}${normalised}`, runtimeConfig.public.siteUrl).toString()
})
useHead({
diff --git a/app/components/AppHeader.vue b/app/components/AppHeader.vue
index bfc5cbf..a58659d 100644
--- a/app/components/AppHeader.vue
+++ b/app/components/AppHeader.vue
@@ -7,7 +7,8 @@ const links = computed(() => navLinks.map(link => ({
})))
const isActive = (to: string) => {
- return to === '/' ? route.path === '/' : route.path.startsWith(to)
+ const current = route.path.endsWith('/') ? route.path : `${route.path}/`
+ return to === '/' ? current === '/' : current.startsWith(to)
}
diff --git a/app/components/HomeView.vue b/app/components/HomeView.vue
index 4437e6d..0753910 100644
--- a/app/components/HomeView.vue
+++ b/app/components/HomeView.vue
@@ -106,6 +106,18 @@ const reveal = {
>>_ {{ current.title }}
+
+
+ {{ page.hero.location }}
+
+
{{ intro.lead }}
diff --git a/app/components/LabDetailView.vue b/app/components/LabDetailView.vue
index 1fb3905..b5e6e64 100644
--- a/app/components/LabDetailView.vue
+++ b/app/components/LabDetailView.vue
@@ -59,7 +59,7 @@ const reveal = {
labs
@@ -375,7 +375,7 @@ const reveal = {
eyebrow="Labs"
heading-id="signal-related-labs"
title="More from the lab"
- :link="{ label: 'All labs', to: '/labs' }"
+ :link="{ label: 'All labs', to: '/labs/' }"
/>
-
speaking
-
@@ -357,7 +357,7 @@ const reveal = {
eyebrow="Speaking"
heading-id="signal-related-talks"
title="More talks"
- :link="{ label: 'All talks', to: '/speaking' }"
+ :link="{ label: 'All talks', to: '/speaking/' }"
/>
-
-
-
-
>(lab: La
}
export const getLabPath = >(lab: Lab) => {
- return `/labs/${getLabSlug(lab)}`
+ return `/labs/${getLabSlug(lab)}/`
}
export const formatLabDate = (value: string | Date) => {
diff --git a/app/utils/links.ts b/app/utils/links.ts
index 1866571..dfb4853 100644
--- a/app/utils/links.ts
+++ b/app/utils/links.ts
@@ -7,9 +7,9 @@ export const navLinks: NavigationMenuItem[] = [{
}, {
label: 'Labs',
icon: 'i-lucide-folder',
- to: '/labs'
+ to: '/labs/'
}, {
label: 'Speaking',
icon: 'i-lucide-mic',
- to: '/speaking'
+ to: '/speaking/'
}]
diff --git a/app/utils/speaking.ts b/app/utils/speaking.ts
index b336ee5..6fe07b3 100644
--- a/app/utils/speaking.ts
+++ b/app/utils/speaking.ts
@@ -116,7 +116,7 @@ export const getTalkSlug = >(talk
}
export const getTalkPath = >(talk: Talk) => {
- return `/speaking/${getTalkSlug(talk)}`
+ return `/speaking/${getTalkSlug(talk)}/`
}
const parseDurationMinutes = (duration?: string): number | null => {
diff --git a/build/generate-content-artifacts.ts b/build/generate-content-artifacts.ts
index e98445b..4950897 100644
--- a/build/generate-content-artifacts.ts
+++ b/build/generate-content-artifacts.ts
@@ -98,7 +98,7 @@ const renderLlmsTxt = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[], ho
lines.push('## Labs')
lines.push('')
for (const lab of labs) {
- lines.push(`- [${lab.title}](${siteUrl}/labs/${lab.slug}): ${collapseWhitespace(lab.description)}`)
+ lines.push(`- [${lab.title}](${siteUrl}/labs/${lab.slug}/): ${collapseWhitespace(lab.description)}`)
}
lines.push('')
}
@@ -108,7 +108,7 @@ const renderLlmsTxt = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[], ho
lines.push('')
for (const talk of talks) {
const meta = [talk.event, talk.location, talk.dateLabel].filter(Boolean).join(' — ')
- lines.push(`- [${talk.title}](${siteUrl}/speaking/${talk.slug}): ${collapseWhitespace(talk.summary)} (${meta})`)
+ lines.push(`- [${talk.title}](${siteUrl}/speaking/${talk.slug}/): ${collapseWhitespace(talk.summary)} (${meta})`)
}
lines.push('')
}
@@ -130,7 +130,7 @@ const renderLlmsFullTxt = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[]
for (const lab of labs) {
const block: string[] = []
block.push(`### ${lab.title}`)
- block.push(`URL: ${siteUrl}/labs/${lab.slug}`)
+ block.push(`URL: ${siteUrl}/labs/${lab.slug}/`)
if (lab.status) block.push(`Status: ${lab.status}`)
if (lab.tags?.length) block.push(`Tags: ${lab.tags.join(', ')}`)
if (lab.url) block.push(`Demo: ${lab.url}`)
@@ -161,7 +161,7 @@ const renderLlmsFullTxt = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[]
for (const talk of talks) {
const block: string[] = []
block.push(`### ${talk.title}`)
- block.push(`URL: ${siteUrl}/speaking/${talk.slug}`)
+ block.push(`URL: ${siteUrl}/speaking/${talk.slug}/`)
block.push(`Event: ${talk.event}`)
block.push(`Location: ${talk.location}`)
if (talk.dateLabel) block.push(`Date: ${talk.dateLabel}`)
@@ -197,8 +197,8 @@ const renderRssXml = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[]) =>
for (const lab of labs) {
pushItem({
title: lab.title,
- link: `${siteUrl}/labs/${lab.slug}`,
- guid: `${siteUrl}/labs/${lab.slug}`,
+ link: `${siteUrl}/labs/${lab.slug}/`,
+ guid: `${siteUrl}/labs/${lab.slug}/`,
pubDate: formatDate(lab.date),
category: 'Labs',
description: collapseWhitespace(lab.description)
@@ -207,8 +207,8 @@ const renderRssXml = (siteUrl: string, labs: LabEntry[], talks: TalkEntry[]) =>
for (const talk of talks) {
pushItem({
title: talk.title,
- link: `${siteUrl}/speaking/${talk.slug}`,
- guid: `${siteUrl}/speaking/${talk.slug}`,
+ link: `${siteUrl}/speaking/${talk.slug}/`,
+ guid: `${siteUrl}/speaking/${talk.slug}/`,
pubDate: formatDate(talk.date),
category: 'Speaking',
description: collapseWhitespace(talk.summary)
diff --git a/content.config.ts b/content.config.ts
index 3ded724..bd82bce 100644
--- a/content.config.ts
+++ b/content.config.ts
@@ -51,6 +51,7 @@ export default defineContentConfig({
hero: z.object({
name: z.string(),
role: z.string(),
+ location: z.string().optional(),
intro: z.string()
}),
focus: createBaseSchema().extend({
diff --git a/content/index.yml b/content/index.yml
index 375d872..56c3866 100644
--- a/content/index.yml
+++ b/content/index.yml
@@ -6,6 +6,7 @@ description: Staff Agentic Engineer at Cordes & Graefe KG focused on organisatio
hero:
name: Fabian Meyer
role: "Staff Agentic Engineer at Cordes\u00A0&\u00A0Graefe\u00A0KG"
+ location: Bremen, Germany
intro: I help companies make the move to agentic engineering. That means shaping how teams work with agents, building the tooling and workflows that make the change stick, and staying hands-on so the practices hold up in real delivery.
focus:
title: Focus
@@ -69,7 +70,7 @@ labs:
link:
label: Explore Labs
icon: i-lucide-flask-conical
- to: /labs
+ to: /labs/
speaking:
title: Speaking
@@ -77,4 +78,4 @@ speaking:
link:
label: View Speaking
icon: i-lucide-mic-vocal
- to: /speaking
+ to: /speaking/
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c9d71d9..3cd291b 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -1,6 +1,8 @@
// Nuxt config — https://nuxt.com/docs/api/configuration/nuxt-config
import { generateContentArtifacts } from './build/generate-content-artifacts'
+const siteUrl = (process.env.NUXT_PUBLIC_SITE_URL || 'https://fmeyer.dev').replace(/\/$/, '')
+
export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
@@ -14,15 +16,19 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
site: {
- url: process.env.NUXT_PUBLIC_SITE_URL || 'https://fmeyer.dev',
+ url: siteUrl,
name: 'fmeyer.dev',
+ // The host serves prerendered pages as directory indexes and 301-redirects
+ // /labs to /labs/. Canonical URLs, sitemap entries and internal links all
+ // use the trailing-slash form so nothing points at a redirect.
+ trailingSlash: true,
description: 'Fabian Meyer — Staff Agentic Engineer at Cordes & Graefe KG, moving the organisation to agentic engineering.',
defaultLocale: 'en'
},
runtimeConfig: {
public: {
- siteUrl: process.env.NUXT_PUBLIC_SITE_URL || 'https://fmeyer.dev',
+ siteUrl,
noindex: process.env.NUXT_PUBLIC_NOINDEX === 'true'
}
},
@@ -130,27 +136,86 @@ export default defineNuxtConfig({
},
schemaOrg: {
+ // Global Person identity: the node every page's WebSite / WebPage / Event /
+ // CreativeWork points at (#identity). Beyond name and job, the properties
+ // exist to disambiguate this Fabian Meyer from the many namesakes Google
+ // knows about: legal name parts, handle, city, employer URL, topics, and
+ // the third-party pages that describe the same person.
identity: {
type: 'Person',
name: 'Fabian Meyer',
- url: process.env.NUXT_PUBLIC_SITE_URL || 'https://fmeyer.dev',
+ givenName: 'Fabian',
+ familyName: 'Meyer',
+ alternateName: 'dinooo13',
+ url: `${siteUrl}/`,
image: '/profile/fabian-meyer-portrait.jpg',
description: 'Staff Agentic Engineer at Cordes & Graefe KG. Leads the organisation-wide move to agentic engineering, building agent-ready tooling, spec-driven development workflows, and enabling engineers to work effectively with agents.',
+ email: 'hello@fmeyer.dev',
jobTitle: 'Staff Agentic Engineer',
worksFor: {
'@type': 'Organization',
- 'name': 'Cordes & Graefe KG'
+ 'name': 'Cordes & Graefe KG',
+ 'url': 'https://www.cordes-graefe.de/'
+ },
+ homeLocation: {
+ '@type': 'Place',
+ 'name': 'Bremen, Germany',
+ 'address': {
+ '@type': 'PostalAddress',
+ 'addressLocality': 'Bremen',
+ 'addressCountry': 'DE'
+ }
},
+ nationality: {
+ '@type': 'Country',
+ 'name': 'Germany'
+ },
+ knowsAbout: [
+ 'Agentic engineering',
+ 'Spec-driven development',
+ 'AI coding agents',
+ { '@type': 'Thing', 'name': 'Software engineering', 'sameAs': 'https://en.wikipedia.org/wiki/Software_engineering' },
+ { '@type': 'Thing', 'name': 'Vue.js', 'sameAs': 'https://en.wikipedia.org/wiki/Vue.js' },
+ { '@type': 'Thing', 'name': 'Nuxt', 'sameAs': 'https://en.wikipedia.org/wiki/Nuxt' },
+ { '@type': 'Thing', 'name': 'TypeScript', 'sameAs': 'https://en.wikipedia.org/wiki/TypeScript' }
+ ],
+ // Profiles first, then third-party pages that name this person with a
+ // matching bio. Reciprocal links from these pages back to fmeyer.dev are
+ // what lets Google confirm they all describe the same entity.
sameAs: [
'https://github.com/dinooo13',
- 'https://linkedin.com/in/fabian-meyer-02038813a'
- ]
+ 'https://linkedin.com/in/fabian-meyer-02038813a',
+ 'https://www.rheinwerk-verlag.de/konferenzen/coding-mit-ki/speaker/#fabian-meyer',
+ 'https://www.rheinwerk-verlag.de/online-kurse/spec-driven-development/',
+ 'https://agentic.hamburg/news/blog/2026/speaker-spotlight-fabian-meyer-onboarding-your-agent-how-eventim-integrates-agentic-engineering-into-enterprise-workflows/'
+ ],
+ subjectOf: {
+ '@type': 'Article',
+ 'headline': 'Speaker Spotlight: Fabian Meyer – Onboarding Your Agent: How eventim Integrates Agentic Engineering into Enterprise Workflows',
+ 'url': 'https://agentic.hamburg/news/blog/2026/speaker-spotlight-fabian-meyer-onboarding-your-agent-how-eventim-integrates-agentic-engineering-into-enterprise-workflows/',
+ 'publisher': {
+ '@type': 'Organization',
+ 'name': 'Agentic Conf Hamburg',
+ 'url': 'https://agentic.hamburg/'
+ }
+ }
}
},
sitemap: {
autoLastmod: true,
xsl: false,
- exclude: ['/404', '/410']
+ exclude: ['/404', '/404/', '/410', '/410/'],
+ // Image discovery reads
out of the prerendered HTML, where the
+ // @nuxt/image `_ipx/fit_cover&s_…` thumbnail URL is already HTML-escaped;
+ // the sitemap escapes it again and emits a URL that 404s. Declare the
+ // portrait explicitly instead — the same file the Person schema uses.
+ discoverImages: false,
+ urls: [
+ {
+ loc: '/',
+ images: [{ loc: '/profile/fabian-meyer-portrait.jpg' }]
+ }
+ ]
}
})
diff --git a/public/.htaccess.tpl b/public/.htaccess.tpl
index 58ce26e..7acd574 100644
--- a/public/.htaccess.tpl
+++ b/public/.htaccess.tpl
@@ -14,6 +14,17 @@
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';"
+# Cache policy. The host applies a blanket one-year max-age to every file. That
+# is right for hashed /_nuxt/ assets but wrong for HTML, feeds and crawler files
+# (sitemap.xml, rss.xml, robots.txt, llms.txt): browsers and feed readers would
+# keep a stale copy until a hard refresh. Force revalidation for those instead;
+# ETag / Last-Modified turn repeat requests into cheap 304s.
+
+
+ Header set Cache-Control "no-cache"
+
+
+
# Custom error pages (prerendered Nuxt routes).
ErrorDocument 404 __BASE__404/index.html
ErrorDocument 410 __BASE__410/index.html
@@ -24,9 +35,19 @@ ErrorDocument 410 __BASE__410/index.html
RewriteEngine On
RewriteBase __BASE__
- RewriteRule ^tags(/.*)?$ - [G,L,NC]
- RewriteRule ^en(/.*)?$ - [G,L,NC]
- RewriteRule ^legal/?$ - [G,L,NC]
+ # Legacy Hugo site (hugo-theme-stack): German content at the root with
+ # posts under /p/, pagination under /page/, plus the theme's list pages;
+ # the English mirror lived under /en/.
+ RewriteRule ^p(/.*)?$ - [G,L,NC]
+ RewriteRule ^page(/.*)?$ - [G,L,NC]
+ RewriteRule ^tags(/.*)?$ - [G,L,NC]
+ RewriteRule ^categories(/.*)?$ - [G,L,NC]
+ RewriteRule ^archives(/.*)?$ - [G,L,NC]
+ RewriteRule ^search(/.*)?$ - [G,L,NC]
+ RewriteRule ^about(/.*)?$ - [G,L,NC]
+ RewriteRule ^en(/.*)?$ - [G,L,NC]
+ RewriteRule ^legal/?$ - [G,L,NC]
+ # Labs that were removed from the current site.
RewriteRule ^labs/watch-wise/?$ - [G,L,NC]
RewriteRule ^labs/tv-show-bingo/?$ - [G,L,NC]
RewriteRule ^labs/fmeyer-dev/?$ - [G,L,NC]