From f99f9dda285de02e99a62e55624abafe3ddbf9fa Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sat, 10 Oct 2026 23:17:57 +0200 Subject: [PATCH 01/11] fix(seo): correct event status for past events The event detail page declared eventStatus EventScheduled for every phase, so a past event still claimed to be upcoming. schema.org has no "completed" status, so a past event now carries no eventStatus instead of a false one. Announced, running and hidden (unlisted, not yet started) events keep EventScheduled, which stays correct for them. The mapping lives in layers/events/utils/eventSchemaStatus.ts and takes the phase the server already decided in useEventDetail, so no clock is read here (see tests/events/phase-on-server.spec.ts). Test: pnpm exec vitest run tests/events/event-schema-status.spec.ts Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- layers/events/utils/eventSchemaStatus.ts | 9 +++++++ pages/events/[...slug].vue | 4 +-- tests/events/event-schema-status.spec.ts | 31 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 2 deletions(-) create mode 100644 layers/events/utils/eventSchemaStatus.ts create mode 100644 tests/events/event-schema-status.spec.ts diff --git a/layers/events/utils/eventSchemaStatus.ts b/layers/events/utils/eventSchemaStatus.ts new file mode 100644 index 00000000..32870198 --- /dev/null +++ b/layers/events/utils/eventSchemaStatus.ts @@ -0,0 +1,9 @@ +import type { EventPhase } from '../types' + +/** + * schema.org eventStatus for an event's phase. schema.org has no "completed" + * value, so a past event gets none rather than a false "scheduled". + */ +export function eventSchemaStatus(phase: EventPhase): string | undefined { + return phase === 'past' ? undefined : 'https://schema.org/EventScheduled' +} diff --git a/pages/events/[...slug].vue b/pages/events/[...slug].vue index 6959bde2..8f489773 100644 --- a/pages/events/[...slug].vue +++ b/pages/events/[...slug].vue @@ -41,7 +41,7 @@ useBreadcrumbs(() => [ // members-only events, so this describes the event for semantic use rather // than chasing a search feature (design.md D10). useSchemaOrg(computed(() => { - if (!event.value) return [] + if (!event.value || !phase.value) return [] const url = new URL(eventDetailPath(locale.value, event.value.slug), site.url).toString() return [ { @@ -54,7 +54,7 @@ useSchemaOrg(computed(() => { endDate: event.value.event.endsAt ? new Date(event.value.event.endsAt).toISOString() : undefined, - eventStatus: 'https://schema.org/EventScheduled', + eventStatus: eventSchemaStatus(phase.value), eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode', location: { '@type': 'VirtualLocation', url }, image: event.value.thumbnail diff --git a/tests/events/event-schema-status.spec.ts b/tests/events/event-schema-status.spec.ts new file mode 100644 index 00000000..b3021db8 --- /dev/null +++ b/tests/events/event-schema-status.spec.ts @@ -0,0 +1,31 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { eventSchemaStatus } from '../../layers/events/utils/eventSchemaStatus' +import { repoRoot } from '../helpers/sources' + +const SCHEDULED = 'https://schema.org/EventScheduled' + +describe('eventSchemaStatus', () => { + it.each([ + 'hidden', + 'announced', + 'running' + ] as const)('marks a %s event as scheduled', (phase) => { + expect(eventSchemaStatus(phase)).toBe(SCHEDULED) + }) + + it('claims no status for a past event', () => { + // schema.org has no "completed" status; EventScheduled would be a false claim. + expect(eventSchemaStatus('past')).toBeUndefined() + }) +}) + +describe('event detail page eventStatus wiring', () => { + const source = readFileSync(join(repoRoot, 'pages/events/[...slug].vue'), 'utf8') + + it('takes the status from the phase instead of hard-coding it', () => { + expect(source).toContain('eventStatus: eventSchemaStatus(phase.value)') + expect(source).not.toContain('EventScheduled') + }) +}) From 5a32292ef033254aa84aa309ec7511888c5325de Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sat, 10 Oct 2026 23:18:02 +0200 Subject: [PATCH 02/11] fix(seo): drop trailing slash from breadcrumb and index URLs The community POI breadcrumb pointed its ancestor at /de/community-poi/, which 301s to the canonical /de/community-poi. Breadcrumb items must name the canonical URL, the same way the locale root was already fixed. The same trailing slash was in the POI back link and in the team fallback URLs (ItemList on the team index, Person on a member without a slug), so they are corrected too. breadcrumb-urls.spec.ts now covers every locale-prefixed template literal ending in a slash, not only the locale root, so the next index page cannot drift back. Test: pnpm exec vitest run tests/seo/breadcrumb-urls.spec.ts Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- pages/community-poi/[...slug].vue | 4 ++-- pages/team/[slug].vue | 2 +- pages/team/index.vue | 2 +- tests/seo/breadcrumb-urls.spec.ts | 24 ++++++++++++++---------- 4 files changed, 18 insertions(+), 14 deletions(-) diff --git a/pages/community-poi/[...slug].vue b/pages/community-poi/[...slug].vue index efe21759..68792283 100644 --- a/pages/community-poi/[...slug].vue +++ b/pages/community-poi/[...slug].vue @@ -29,7 +29,7 @@ usePageSeo({ useBreadcrumbs(() => [ { name: t('navigation.home'), url: `/${locale.value}` }, - { name: t('community_poi.overview.title'), url: `/${locale.value}/community-poi/` }, + { name: t('community_poi.overview.title'), url: `/${locale.value}/community-poi` }, { name: title.value } ]) @@ -99,7 +99,7 @@ const progressSectionClass = 'rounded-large bg-surface-container-low p-5'

- + ← {{ t('community_poi.detail.back') }}

diff --git a/pages/team/[slug].vue b/pages/team/[slug].vue index b7679da0..e8766423 100644 --- a/pages/team/[slug].vue +++ b/pages/team/[slug].vue @@ -99,7 +99,7 @@ useSchemaOrg(computed(() => { if (!member.value) return [] const profileUrl = member.value.slug ? personProfileUrl(site.url, locale.value, member.value.slug) - : new URL(`/${locale.value}/team/`, site.url).toString() + : new URL(`/${locale.value}/team`, site.url).toString() const avatar = avatarSrc.value?.startsWith('http') ? avatarSrc.value : new URL(avatarSrc.value || '/favicon.svg', site.url).toString() diff --git a/pages/team/index.vue b/pages/team/index.vue index 526d8cab..0423f4b5 100644 --- a/pages/team/index.vue +++ b/pages/team/index.vue @@ -42,7 +42,7 @@ useSchemaOrg(computed(() => { position: index + 1, url: m.slug ? personProfileUrl(site.url, locale.value, m.slug) - : new URL(`/${locale.value}/team/`, site.url).toString(), + : new URL(`/${locale.value}/team`, site.url).toString(), name: m.name })) } diff --git a/tests/seo/breadcrumb-urls.spec.ts b/tests/seo/breadcrumb-urls.spec.ts index eb139d55..205c6e46 100644 --- a/tests/seo/breadcrumb-urls.spec.ts +++ b/tests/seo/breadcrumb-urls.spec.ts @@ -17,17 +17,19 @@ import { collectSourceFiles, relativeToRepo } from '../helpers/sources' * append a trailing slash by default and nothing here overrides that, so the * canonical form is the one without. * - * Checked against the source rather than rendered output because the value is - * a template literal built per page — nine of them, which is exactly the kind - * of repetition that drifts. + * The same holds for every locale-prefixed index URL, not only the locale + * root: the community POI index was `/de/community-poi/` in its breadcrumb, + * and the team index was built with a trailing slash as a fallback in + * ItemList and Person schema. Checked against the source because the values + * are template literals built per page. */ const SOURCE_DIRS = ['pages', 'composables', 'layers'] -/** `url: \`/${locale.value}/\`` — a locale root with a trailing slash. */ -const LOCALE_ROOT_WITH_SLASH = /url:\s*`\/\$\{locale\.value\}\/`/ +/** A locale-prefixed template literal ending in a slash, e.g. `/${locale.value}/team/`. */ +const LOCALE_PATH_WITH_SLASH = /`\/\$\{locale(?:\.value)?\}[^`]*\/`/ function offenders(): string[] { const found: string[] = [] @@ -35,7 +37,7 @@ function offenders(): string[] { const text = readFileSync(file, 'utf8') const lines = text.split('\n') lines.forEach((line, index) => { - if (LOCALE_ROOT_WITH_SLASH.test(line)) { + if (LOCALE_PATH_WITH_SLASH.test(line)) { found.push(`${relativeToRepo(file)}:${index + 1}`) } }) @@ -44,13 +46,15 @@ function offenders(): string[] { } describe('breadcrumb ancestor URLs', () => { - it('recognises the trailing-slash form', () => { + it('recognises the trailing-slash forms', () => { // Without this the check could pass by matching nothing at all. - expect(LOCALE_ROOT_WITH_SLASH.test('{ name: t(\'navigation.home\'), url: `/${locale.value}/` }')).toBe(true) - expect(LOCALE_ROOT_WITH_SLASH.test('{ name: t(\'navigation.home\'), url: `/${locale.value}` }')).toBe(false) + expect(LOCALE_PATH_WITH_SLASH.test('{ name: t(\'navigation.home\'), url: `/${locale.value}/` }')).toBe(true) + expect(LOCALE_PATH_WITH_SLASH.test('{ url: `/${locale.value}/community-poi/` }')).toBe(true) + expect(LOCALE_PATH_WITH_SLASH.test('{ name: t(\'navigation.home\'), url: `/${locale.value}` }')).toBe(false) + expect(LOCALE_PATH_WITH_SLASH.test('{ url: `/${locale.value}/community-poi` }')).toBe(false) }) - it('point at the canonical locale root, without a trailing slash', () => { + it('point at canonical locale-prefixed URLs, without a trailing slash', () => { expect(offenders()).toEqual([]) }) }) From 9990726eee692005e38a76915c80eab494216e95 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Sat, 10 Oct 2026 23:18:10 +0200 Subject: [PATCH 03/11] fix(seo): use absolute social image for POI JSON-LD The POI CreativeWork took the raw frontmatter thumbnail (/images/...) as its image and left it relative. In production those images resolve through img.onelitefeather.net, so the path on onelitefeather.net returns 404 and the structured data pointed at a missing file. usePageSeo now returns the socialImage it already computes for og:image, and the POI page uses that URL for the CreativeWork image. Both tags therefore share one resolution path and one absolute, transformed URL. Test: pnpm exec vitest run tests/seo/poi-jsonld-image.spec.ts Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- layers/content-core/composables/usePageSeo.ts | 3 +++ pages/community-poi/[...slug].vue | 4 ++-- tests/seo/poi-jsonld-image.spec.ts | 24 +++++++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) create mode 100644 tests/seo/poi-jsonld-image.spec.ts diff --git a/layers/content-core/composables/usePageSeo.ts b/layers/content-core/composables/usePageSeo.ts index f6009d6e..5746e232 100644 --- a/layers/content-core/composables/usePageSeo.ts +++ b/layers/content-core/composables/usePageSeo.ts @@ -125,4 +125,7 @@ export function usePageSeo(opts: PageSeoOptions = {}) { inLanguage: locale?.value || DEFAULT_LOCALE }) ])) + + // Exposed so page JSON-LD can reuse the og:image URL instead of resolving its own. + return { socialImage } } diff --git a/pages/community-poi/[...slug].vue b/pages/community-poi/[...slug].vue index 68792283..2168003b 100644 --- a/pages/community-poi/[...slug].vue +++ b/pages/community-poi/[...slug].vue @@ -16,7 +16,7 @@ const { projects: linkedProjects } = await useProjectsBySlugs(() => poi.value?.p const title = computed(() => poi.value?.title || t('community_poi.overview.title')) const description = computed(() => poi.value?.summary || t('community_poi.overview.description')) -usePageSeo({ +const { socialImage } = usePageSeo({ title: title.value, description: description.value, image: poi.value?.thumbnail, @@ -55,7 +55,7 @@ useSchemaOrg(computed(() => { url: detailUrl, dateCreated: toIso(poi.value.startedAt as string | Date | undefined), dateModified: toIso(poi.value.updatedAt as string | Date | undefined), - image: poi.value.thumbnail || undefined, + image: poi.value.thumbnail ? socialImage.value.url : undefined, author: (poi.value.builders || []).map((b) => ({ '@type': 'Person' as const, name: b.name })) } ] diff --git a/tests/seo/poi-jsonld-image.spec.ts b/tests/seo/poi-jsonld-image.spec.ts new file mode 100644 index 00000000..d173b2ef --- /dev/null +++ b/tests/seo/poi-jsonld-image.spec.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { repoRoot } from '../helpers/sources' + +/** + * The POI page's CreativeWork image was the raw frontmatter path, resolved + * against the site origin. Those images are served by img.onelitefeather.net + * in production, so the JSON-LD pointed at a 404. It must use the same + * absolute, transformed URL as og:image, which usePageSeo already computes. + */ +const poiSource = readFileSync(join(repoRoot, 'pages/community-poi/[...slug].vue'), 'utf8') +const seoSource = readFileSync(join(repoRoot, 'layers/content-core/composables/usePageSeo.ts'), 'utf8') + +describe('community POI JSON-LD image', () => { + it('uses the resolved social image, not the raw thumbnail path', () => { + expect(poiSource).toMatch(/image: poi\.value\.thumbnail \? socialImage\.value\.url : undefined/) + }) + + it('takes that image from usePageSeo, the one og:image uses', () => { + expect(poiSource).toMatch(/const \{ socialImage \} = usePageSeo\(/) + expect(seoSource).toMatch(/return \{ socialImage \}/) + }) +}) From 8ab2142a87fb2e2b56d07dd6e0fd10ecad4b768a Mon Sep 17 00:00:00 2001 From: Phillipp Glanz <6745190+TheMeinerLP@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:48:48 +0200 Subject: [PATCH 04/11] docs(skills): bring the nuxt-seo skill up to date (#465) The emitter table, module versions and the sitemap version tell no longer matched main, and violations fixed since were still listed as live. The open zeroRuntime gap stays documented. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- .claude/skills/nuxt-seo/SKILL.md | 91 +++++++++++-------- .../references/i18n-sitemap-robots.md | 28 +++--- .../skills/nuxt-seo/references/og-images.md | 19 ++-- .../nuxt-seo/references/seo-utils-defaults.md | 24 ++--- 4 files changed, 84 insertions(+), 78 deletions(-) diff --git a/.claude/skills/nuxt-seo/SKILL.md b/.claude/skills/nuxt-seo/SKILL.md index 4938e011..621ced47 100644 --- a/.claude/skills/nuxt-seo/SKILL.md +++ b/.claude/skills/nuxt-seo/SKILL.md @@ -2,56 +2,54 @@ name: nuxt-seo description: >- Covers SEO for this Nuxt 4 site — canonical and hreflang ownership via - useLocaleHead, @nuxtjs/i18n locale objects, @nuxtjs/seo module version floors, - site URL resolution, Schema.org identity and @ids, robots directives, sitemap - sources, and OG image generation with nuxt-og-image. Use when editing the i18n, - site, sitemap, robots, schemaOrg or ogImage blocks in nuxt.config.ts, when - touching usePageSeo, useArticleSeo, useLocaleHead, useHead, useSeoMeta, + @nuxtjs/i18n's strictSeo, @nuxtjs/i18n locale objects, @nuxtjs/seo module version + floors, site URL resolution, Schema.org identity and @ids, robots directives, + sitemap sources, and OG image generation with nuxt-og-image. Use when editing the + i18n, site, sitemap, robots, schemaOrg or ogImage blocks in nuxt.config.ts, when + touching usePageSeo, useArticleSeo, useHead, useSeoMeta, defineOgImage or JSON-LD, when adding a locale or a page that needs metadata, or when hreflang, canonical tags, og:image, robots.txt or sitemap.xml entries are missing, duplicated or wrong. --- -Nuxt 4.4 · @nuxtjs/seo 5.1 · @nuxtjs/i18n 10.3 · @nuxt/content 3.14 · Tailwind 4.3 · Cloudflare Workers + D1 +Nuxt 4.5 · @nuxtjs/seo 5.3 · @nuxtjs/i18n 10.6 · @nuxt/content 3.16 · Tailwind 4.3 · Cloudflare Workers + D1 ## Emitter ownership map | tag | emitted by | file | |---|---|---| -| `rel=canonical`/`rel=alternate` (hreflang), `og:locale` | `useLocaleHead()` | `layouts/default.vue` | -| title/description, robots, OG/Twitter, per-page JSON-LD | `usePageSeo()`/`useArticleSeo()` | `composables/*Seo.ts` | +| `rel=canonical`/`rel=alternate` (hreflang), `og:locale`, `` | `@nuxtjs/i18n`, `experimental.strictSeo` | `nuxt.config.ts` (`i18n`) | +| title/description, robots, OG/Twitter, per-page JSON-LD | `usePageSeo()`/`useArticleSeo()` | `layers/*/composables/*Seo.ts` | | sitemap: extra sources / a collection's own `path`-`loc` fix | `sitemap.sources` / — | `server/api/__sitemap__/` / `content.config.ts` (`nuxt-content-cms`) | -Hard rule: no `rel: 'canonical'`/`rel: 'alternate'` outside `layouts/default.vue`. +Hard rule: no `rel: 'canonical'`/`rel: 'alternate'` in app code, and no +`useLocaleHead()`. `strictSeo` owns those tags, and AGENTS.md says +`useLocaleHead` is not allowed in this mode. ## Locale objects need `language`, never `iso` `@nuxtjs/i18n` reads `locale.language` to build the hreflang map (`node_modules/@nuxtjs/i18n/dist/runtime/kit/head.js`, `createLocaleMap`) — a locale with no `language` is skipped with a console warning and gets no -alternate link at all. This repo's `nuxt.config.ts` writes `iso: 'de-DE'` / -`iso: 'en-US'` instead: write `{code: 'de', language: 'de-DE', …}`, never +alternate link at all. Write `{code: 'de', language: 'de-DE', …}`, never `{code: 'de', iso: 'de-DE', …}`. TypeScript never flags it — `LocaleObject` carries an index signature, so `iso` type-checks fine and is ignored at -runtime. Migrating the existing `de`/`en` entries is out of scope; that does -**not** license copying the mistake into a new one. Grep any diff that adds a -locale for `iso:`; that string should not appear in it. +runtime. Grep any diff that adds a locale for `iso:`; that string should not +appear in it. ## Env blocks concatenate arrays `$production` (and `$env.`, and layer `extends`) merge into the base config through `c12`'s `merger`, which for two arrays does `obj[key] = -obj[key].concat(value)` (`@nuxt/kit`'s `loadNuxtConfig`), never a replace. Four -footguns in this repo's `$production` block: `i18n.locales`, -`schemaOrg.identity.sameAs`, `schemaOrg.identity.contactPoint` and -`image.format` are each restated in full there, so `concat` doubles every entry -that also exists in the base config. Add a locale (or a `sameAs` link) to the -base config only — never to both, the bug this skill's own baseline reproduced. +obj[key].concat(value)` (`@nuxt/kit`'s `loadNuxtConfig`), never a replace. An +array declared in both the base config and `$production` is doubled, not +overridden. `tests/architecture/production-config.spec.ts` enforces this. Add a +locale (or a `sameAs` link) to the base config only — never to both. ## `@nuxtjs/seo` is a meta-module — read `node_modules/`, not the lockfile It ships no features of its own. It declares a floor per sub-module in -`moduleDependencies` (`@nuxtjs/seo@5.1.4`'s `dist/module.mjs`); Nuxt resolves +`moduleDependencies` (`@nuxtjs/seo@5.3.16`'s `dist/module.mjs`); Nuxt resolves each name from the **project's** `node_modules` and semver-checks it there. Those floors, not the `dependencies` ranges, are what fails a build: @@ -62,14 +60,20 @@ Those floors, not the `dependencies` ranges, are what fails a build: | `nuxt-link-checker` | ≥4.3 | — | — | — | — | Diagnostic: what loads is whatever `node_modules//package.json` says. A -direct pin in this repo's `package.json` wins over the newer copy `@nuxtjs/seo` -drags into its own tree, and a lockfile lists both — so the lockfile alone can -never answer this. Worked example: `package.json` pins robots 5.7.1, sitemap -7.6.0, schema-org 5.0.10; `@nuxtjs/seo`'s `dependencies` additionally resolve -6.1.2 / 8.0.15 / 6.2.3 under `node_modules/.pnpm/`. Only the pins load (grep -`.nuxt/` for `.pnpm/@nuxtjs+sitemap@`), all clear the floors above, and the -build is clean — but the API you get is v7's. Tell the two apart by export: -`defineSitemapSchema` ⇒ sitemap v8, only `asSitemapCollection` ⇒ v7 (this repo). +lockfile can list more than one copy of a module, so read `node_modules`, not +the lockfile. Current resolved versions: `@nuxtjs/robots` 6.2.4, +`@nuxtjs/sitemap` 8.5.1, `nuxt-schema-org` 6.4.3, `nuxt-og-image` 6.10.3 (direct +pins in `package.json`), `nuxt-seo-utils` 8.6.1, `nuxt-site-config` 4.2.3 and +`nuxt-link-checker` 5.3.0 (not direct dependencies; resolved through +`@nuxtjs/seo` under `node_modules/.pnpm/`). All clear the floors above. +`@nuxtjs/seo`'s own `dependencies` ranges (`^6.1.5`, `^8.3.4`, `^6.2.9`) are +satisfied by the pins, so there is one copy of each. + +The sitemap major version is not told by export names: `dist/content.mjs` of +8.5.1 exports both `asSitemapCollection` and `defineSitemapSchema`. This repo's +`content.config.ts` uses `defineSitemapSchema` (imported from +`@nuxtjs/sitemap/content`) and never `asSitemapCollection`. Read the version +from `node_modules/@nuxtjs/sitemap/package.json`. ## One site URL — but `i18n.baseUrl` outranks it @@ -79,7 +83,7 @@ entry at `_priority: -1`, above `nuxt.config.ts`'s `site` block (`-3`). But the entry it pushes from `i18n.baseUrl` carries **no** `_priority` (so, 0) and is pushed last, and the stack sorts ascending with later entries overwriting — so `i18n.baseUrl` beats `NUXT_SITE_URL`, at build and at request time. This repo -sets `i18n.baseUrl` (`nuxt.config.ts:94`), so setting `NUXT_SITE_URL` alone +sets `i18n.baseUrl` (`nuxt.config.ts:142`), so setting `NUXT_SITE_URL` alone would silently do nothing. Change `i18n.baseUrl` too, or drop it so the env var can win; either way don't hand-edit `site.url` per environment block. @@ -102,17 +106,25 @@ of the sitemap automatically. 2. **Every `defineOgImage()` call uses a locally-owned template**, not one of the 12 the module ships under `runtime/app/components/Templates/Community/` — they live inside the module, not under this repo's `components/OgImage/`. - Here `composables/usePageSeo.ts` and `composables/useArticleSeo.ts` both call - `defineOgImage('NuxtSeo', …)` while only `TeamMember.satori.vue` exists - locally. `npx nuxt-og-image eject NuxtSeo` copies it to - `components/OgImage/NuxtSeo.satori.vue`, where you own it. + `npx nuxt-og-image eject NuxtSeo` copies it to + `components/OgImage/NuxtSeo.satori.vue`, where you own it. Current state: + `layers/content-core/composables/usePageSeo.ts` and + `layers/blog/composables/useArticleSeo.ts` call + `defineOgImage('NuxtSeo.satori', …)`. The module's own copy matches that + name too, so confirm the rendered image, not just the call. 3. **The renderer suffix matches the template file** — `.satori.vue`, `.takumi.vue` or `.browser.vue`; v6 picks the renderer from the filename, not a `defaults` config key (`nuxt-og-image/dist/runtime/types.d.ts`). + The suffix in the call name (`'NuxtSeo.satori'`) is what selects the + Satori variant when the module also ships a takumi one. -Precondition 1 alone is not enough: a prerender crawl still renders the -un-ejected community template unless 2 is also done. Both are real, open -defects here; fixing them is out of scope. +Current state: `nuxt.config.ts` sets `ogImage.zeroRuntime: true` (line ~447) +and has no `nitro` block, so precondition 1 fails and no OG images are +generated. Pages fall back to `/images/og-default.png` +(`layers/content-core/utils/socialImage.ts`). The ejected template +`components/OgImage/NuxtSeo.satori.vue` still renders the Nuxt SEO logo and +wordmark, so it has to be replaced with brand artwork before a prerender pass +ships its output. ## References @@ -144,8 +156,9 @@ consent mechanism exists. fields, a `noindex` route that still appears in the sitemap. Fix the composable or config it points at; re-run step 1. 3. View-source a page and count `rel="canonical"` (must be 1) and - `rel="alternate"` (must equal the locale count) — more than one canonical, - or one missing alternate, means a second emitter crept in. + `rel="alternate"` (one per locale the page publishes route params for; + `strictSeo` omits the rest) — more than one canonical, or an alternate + missing for a locale that has the page, means a second emitter crept in. 4. CI gates: `.github/workflows/seo.yml` runs the same check plus Lighthouse against `.lighthouserc.json` (`categories:seo` ≥ 0.95). Repeat from step 1 until both pass locally. diff --git a/.claude/skills/nuxt-seo/references/i18n-sitemap-robots.md b/.claude/skills/nuxt-seo/references/i18n-sitemap-robots.md index 1a6d0161..97a14c62 100644 --- a/.claude/skills/nuxt-seo/references/i18n-sitemap-robots.md +++ b/.claude/skills/nuxt-seo/references/i18n-sitemap-robots.md @@ -9,9 +9,8 @@ each internally flagged `includeAppSources: true`. The children do **not** live at `/sitemap_.xml`. They live under `sitemapsPathPrefix` (default `/__sitemap__/`), named from -`_sitemap = locale.language || locale.code`. The module normalises a legacy -`iso:` key into `language` itself (`dist/module.mjs`, `normalizeLocales`), so -this repo's `iso: 'de-DE'` entries still yield `/__sitemap__/de-DE.xml` and +`_sitemap = locale.language || locale.code`. This repo's locales set `language` +(`de-DE`, `en-US`), so the children are `/__sitemap__/de-DE.xml` and `/__sitemap__/en-US.xml`. The index is `/sitemap_index.xml`; `/sitemap.xml` does not aggregate them — it gets a `routeRules` **redirect** to `/sitemap_index.xml`. Fetch `/sitemap_index.xml` when you want the list of @@ -44,10 +43,10 @@ used internally for i18n `pages` mappings, not something a hand-written Content that isn't a URL-bearing page collection (this repo's `team`, a `type: 'data'` collection) doesn't produce sitemap entries on its own. -`server/api/__sitemap__/team.ts` is registered via `sitemap.sources: ['/api/__sitemap__/team']` -in `nuxt.config.ts` and returns a flat `{ loc }[]` array, one entry per -locale × member — the same pattern to follow for any other non-page content -that needs sitemap URLs. See `nuxt-content-cms` for the split: page +`server/api/__sitemap__/team.ts` is one of three handlers registered via +`sitemap.sources` in `nuxt.config.ts` (with `events` and `blog-authors`). Each +returns a flat `{ loc }[]` array, one entry per locale × item — the same +pattern to follow for any other non-page content that needs sitemap URLs. See `nuxt-content-cms` for the split: page collections' own `path`/`loc` bugs are fixed in `content.config.ts`; sources like this one live in `nuxt.config.ts`/`server/api/__sitemap__/`. @@ -60,15 +59,12 @@ renders the columns `sitemap.xslColumns` declares, emitting each one as `` inside a `for-each` over `sitemap:urlset/sitemap:url`. -This repo's `nuxt.config.ts` adds a `Language` column with -`select: 'sitemap:hreflang'` — that XPath asks for a child *element* named -`hreflang` in the sitemap namespace, and no such element exists, so the column -renders empty on every row. The alternates are an *attribute* on an element in -the `xhtml` namespace (which the stylesheet does declare), so the expression -would have to be something like `xhtml:link/@hreflang`. Fixing `nuxt.config.ts` -is out of scope here — just don't read the empty column as "hreflang missing". -Diagnostic: never trust the XSL-rendered table; view-source or `curl` the XML -directly and grep for `hreflang=`. +A `sitemap.xslColumns` entry that shows alternates must select +`xhtml:link/@hreflang` (this repo's `Language` column does). `sitemap:hreflang` +asks for a child element in the sitemap namespace that does not exist, so that +column renders empty on every row. Don't read an empty column as "hreflang +missing". Diagnostic: never trust the XSL-rendered table; view-source or `curl` +the XML directly and grep for `hreflang=`. ## Robots: `routeRules` is not auto-translated per locale diff --git a/.claude/skills/nuxt-seo/references/og-images.md b/.claude/skills/nuxt-seo/references/og-images.md index ae57ec27..d144f609 100644 --- a/.claude/skills/nuxt-seo/references/og-images.md +++ b/.claude/skills/nuxt-seo/references/og-images.md @@ -19,7 +19,7 @@ composable, and pass it in as a prop: ```ts // composable — resolve locale-aware strings first -defineOgImage('NuxtSeo', { +defineOgImage('NuxtSeo.satori', { title: opts.title || site.name, description: pageDescription.value }) @@ -70,14 +70,15 @@ automatically. `NuxtSeo` ships both a `.satori.vue` and a `.takumi.vue`). Don't read the `COMMUNITY_TEMPLATES = ["NuxtSeo", "Brutalist", "SimpleBlog"]` constant in `dist/cli.cjs` as the full list: it is only the subset the v6 migration -command scans for. Calling `defineOgImage('NuxtSeo', …)` -works in dev because the dev server can render the module's own copy on -demand, but the module's own CLI treats this as unfinished migration work — -`npx nuxt-og-image eject ` copies the template source into -`components/OgImage/.satori.vue` in this repo, where you can edit it and -where `zeroRuntime`'s prerender pass can pick it up as a local file. See the -top-level "`zeroRuntime` has three preconditions" section — ejecting the -template is precondition 2, independent of prerendering (precondition 1). +command scans for. A `defineOgImage('NuxtSeo…')` call works in dev because the +dev server can render the module's own copy on demand, but the module's CLI +treats that as unfinished migration work — `npx nuxt-og-image eject ` +copies the template source into `components/OgImage/.satori.vue` in this +repo, where you can edit it and where `zeroRuntime`'s prerender pass can pick +it up as a local file. The `NuxtSeo` template is ejected here +(`components/OgImage/NuxtSeo.satori.vue`). See the top-level "`zeroRuntime` has +three preconditions" section — ejecting is precondition 2, independent of +prerendering (precondition 1). ## Fonts diff --git a/.claude/skills/nuxt-seo/references/seo-utils-defaults.md b/.claude/skills/nuxt-seo/references/seo-utils-defaults.md index 68c5bd5f..71a3106e 100644 --- a/.claude/skills/nuxt-seo/references/seo-utils-defaults.md +++ b/.claude/skills/nuxt-seo/references/seo-utils-defaults.md @@ -3,24 +3,20 @@ `nuxt-seo-utils` (bundled by `@nuxtjs/seo`) runs an `applyDefaults()` plugin with `automaticDefaults: true` by default — no config in this repo turns it on, it just runs. It pushes its own `og:locale`, `og:url`, `og:site_name`, -title template and a `rel="canonical"` link, all at `tagPriority: 'low'`. In -practice this means it's a **fallback**, not a second live emitter: Unhead's -tag-priority dedup keeps `layouts/default.vue`'s normal-priority -`useLocaleHead()` canonical over this low-priority one, so the "one emitter" -rule in the top-level `Emitter ownership map` still holds today. It only -becomes live output if the app-wide `useLocaleHead()` call is ever removed — -worth knowing before you assume canonical has no other source when tracing a -head-rendering bug. +title template and a `rel="canonical"` link, all at `tagPriority: 'low'`. It is +a **fallback**, not a second live emitter: a low-priority tag yields to any +normal-priority canonical, and in this repo the canonical comes from +`@nuxtjs/i18n` (`strictSeo`). It only shows when nothing else sets one. Check +view-source before assuming which canonical is live. ## `canonicalLowercase` (default: `true`) The fallback canonical above lowercases the URL path -(`url.toLocaleLowerCase(locale)`). `useLocaleHead()`'s hreflang self-reference -does **not** lowercase. `usePageSeo.ts`'s own comment states canonical "must -be byte-identical to this locale's self-referencing hreflang entry" — if this -fallback ever becomes the active canonical (see above) on a route with any -uppercase path segment (a slug, for instance), that byte-identity breaks -silently. Currently masked by the low-priority override, not fixed. +(`url.toLocaleLowerCase(locale)`). `usePageSeo.ts:40` requires the canonical to +be byte-identical to this locale's self-referencing hreflang entry (emitted by +`@nuxtjs/i18n`). If the fallback ever becomes the active canonical on a route +with an uppercase path segment (a slug, for instance), that identity breaks +silently. ## `canonicalQueryWhitelist` (default: a fixed list) From 0cf8560f4afbd6f9f095e4374404e1648e17ba1b Mon Sep 17 00:00:00 2001 From: Phillipp Glanz <6745190+TheMeinerLP@users.noreply.github.com> Date: Sat, 10 Oct 2026 23:48:57 +0200 Subject: [PATCH 05/11] perf(community-poi): load the bluemap viewer on click (#466) The embedded BlueMap started loading at page load despite loading="lazy": on the Yggdrasil page that was 284 requests and about 70 MB on mobile. A placeholder button now mounts the iframe only when asked for. Claude-Session: https://claude.ai/code/session_019zws4ceJu2jctk3XT48fMX --- i18n/locales/de.json | 4 +- i18n/locales/en.json | 4 +- .../components/CommunityPoiBluemap.vue | 32 +++++++-- tests/community/bluemap-click-to-load.spec.ts | 66 +++++++++++++++++++ 4 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 tests/community/bluemap-click-to-load.spec.ts diff --git a/i18n/locales/de.json b/i18n/locales/de.json index 8d1bc645..041ff914 100644 --- a/i18n/locales/de.json +++ b/i18n/locales/de.json @@ -298,7 +298,9 @@ "open": "Auf BlueMap öffnen", "link_aria": "{title} auf der BlueMap öffnen (neuer Tab)", "iframe_title": "BlueMap-Vorschau für {title}", - "embed_note": "Vorschau geladen von {host}. Für volle Funktionen die externe Seite öffnen." + "load": "Karte laden", + "load_hint": "Die interaktive Karte lädt erst, wenn du sie startest.", + "embed_note": "Vorschau von {host}. Für volle Funktionen die externe Seite öffnen." }, "facing": { "north": "Norden", diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 6b9d5e66..876c10f7 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -298,7 +298,9 @@ "open": "Open on BlueMap", "link_aria": "Open {title} on BlueMap (new tab)", "iframe_title": "BlueMap preview for {title}", - "embed_note": "Preview loaded from {host}. Open the external site for full functionality." + "load": "Load map", + "load_hint": "The interactive map only loads when you start it.", + "embed_note": "Preview from {host}. Open the external site for full functionality." }, "facing": { "north": "North", diff --git a/layers/community-poi/components/CommunityPoiBluemap.vue b/layers/community-poi/components/CommunityPoiBluemap.vue index 58a203f9..a42833b2 100644 --- a/layers/community-poi/components/CommunityPoiBluemap.vue +++ b/layers/community-poi/components/CommunityPoiBluemap.vue @@ -1,5 +1,5 @@