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
91 changes: 52 additions & 39 deletions .claude/skills/nuxt-seo/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`, `<html lang/dir>` | `@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.<name>`, 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:

Expand All @@ -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/<pkg>/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

Expand All @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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.
28 changes: 12 additions & 16 deletions .claude/skills/nuxt-seo/references/i18n-sitemap-robots.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,8 @@ each internally flagged `includeAppSources: true`.

The children do **not** live at `/sitemap_<code>.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
Expand Down Expand Up @@ -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__/`.

Expand All @@ -60,15 +59,12 @@ renders the columns `sitemap.xslColumns` declares, emitting each one as
`<xsl:value-of select="{c.select}"/>` 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

Expand Down
19 changes: 10 additions & 9 deletions .claude/skills/nuxt-seo/references/og-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
})
Expand Down Expand Up @@ -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 <name>` copies the template source into
`components/OgImage/<name>.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 <name>`
copies the template source into `components/OgImage/<name>.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

Expand Down
24 changes: 10 additions & 14 deletions .claude/skills/nuxt-seo/references/seo-utils-defaults.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion content/about/de/home.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"key": "about",
"who": "OneLiteFeather ist ein Minecraft-Netzwerk, das von seiner Community lebt – und von Menschen, die gerne bauen, spielen und entwickeln. 2021 als OneLiteFeather gegründet, wachsen wir seitdem Stück für Stück: mit eigenen Spielmodi, Gemeinschaftsbauten und Software, die wir offen teilen.",
"who": "OneLiteFeather ist ein Minecraft-Netzwerk, das von seiner Community lebt – und von Menschen, die gerne bauen, spielen und entwickeln. 2019 als OneLiteFeather gegründet, wachsen wir seitdem Stück für Stück: mit eigenen Spielmodi, Gemeinschaftsbauten und Software, die wir offen teilen.",
"pillars": [
{
"icon": "cube",
Expand Down
2 changes: 1 addition & 1 deletion content/about/en/home.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"key": "about",
"who": "OneLiteFeather is a Minecraft network that lives on its community – and on people who love to build, play and develop. Founded in 2021 as OneLiteFeather, we have been growing step by step ever since: with our own game modes, community builds and software that we share openly.",
"who": "OneLiteFeather is a Minecraft network that lives on its community – and on people who love to build, play and develop. Founded in 2019 as OneLiteFeather, we have been growing step by step ever since: with our own game modes, community builds and software that we share openly.",
"pillars": [
{
"icon": "cube",
Expand Down
2 changes: 1 addition & 1 deletion content/blog/de/dev-blog-1.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ alternativeTitle: 'Welche Technologien wir nutzen und warum'
description: 'Heute möchte ich aus unserem Team die aktiv verwenden Technologien vorstellen und was die für Vorteile uns bringen. '
pubDate: '2023-10-21'
headerImage: 'images/blog/dev-blog-1.webp'
headerImageAlt: 'Title Bild für DevBlog #1'
headerImageAlt: 'Konferenzraum mit einem Vortragenden vor einer Wand voller Dashboards und Diagramme'
slug: 'dev-blog-1-was-wir-verwenden'
translationKey: 'dev-blog-1-what-we-using'
author: themeinerlp
Expand Down
Loading
Loading