fix: canonical and social URLs answered as paths become absolute on the request origin

This commit is contained in:
Jakub Zych
2026-10-31 13:53:30 +01:00
parent ac59f3c80d
commit e9a5045737
8 changed files with 192 additions and 32 deletions

View File

@@ -0,0 +1,7 @@
<script setup lang="ts">
useSeoMeta({ title: 'Relative own' })
</script>
<template>
<main><h1>Relative</h1></main>
</template>

View File

@@ -10,7 +10,7 @@ export default defineNuxtConfig({
```
```json
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.1.0"
"vue-seo-layer": "git+https://git.golem15.com/golem15/vue-seo-layer.git#v0.1.1"
```
The layer declares no Nuxt modules and works next to `@nuxtjs/seo`, the Journal layer or an app's own `useSeoMeta`.
@@ -20,7 +20,7 @@ The layer declares no Nuxt modules and works next to `@nuxtjs/seo`, the Journal
- **Resolve.** A global route middleware calls `GET <apiBase>/resolve?url=<path and query>` before the page runs, on the server for the first request and in the browser on each client-side navigation (the server's answer travels in the payload, so hydration fetches nothing). Nuxt's own paths (`/_…`, `/api/…`) are never resolved. A call that fails, times out (`timeoutMs`) or answers anything unexpected is ignored: the page renders with its own head.
- **Redirects.** `redirect` answers 301 or 302 with `Location`. A target on the site's own origin (the origin of the answer's `url`) becomes a path, so a staging or preview host keeps the visitor even when the backend's site URL names production; a target on another host stays absolute. The redirect wins over the route, so an old address no page matches still redirects.
- **410.** `gone` answers 410 with the app's error page, also on a route that would render.
- **Head.** An `ok` answer sets the title (final; the plugin applies its templates, so the app's `titleTemplate` is replaced by `%s`), `description`, `canonical`, `robots`, `og:title`, `og:description`, `og:image`, `og:url`, `twitter:card`, `twitter:site` and the verification meta tags. The entry has `tagPriority: 'critical'`, so its tags win over the same tags set by a page, a layer or a module; a value the answer leaves empty adds no tag, and the page's own stays. `not_found` answers (pages the plugin does not know: listings, search, author pages, static pages without a URL rule) leave the head to the page.
- **Head.** An `ok` answer sets the title (final; the plugin applies its templates, so the app's `titleTemplate` is replaced by `%s`), `description`, `canonical`, `robots`, `og:title`, `og:description`, `og:image`, `og:url`, `twitter:card`, `twitter:site` and the verification meta tags. The entry has `tagPriority: 'critical'`, so its tags win over the same tags set by a page, a layer or a module; a value the answer leaves empty adds no tag, and the page's own stays. A canonical, `og:url` or `og:image` the plugin answers as a path (a backend without `golem15.seo.site_url` or `app.url`) is made absolute on the request's origin; set the site URL on the backend so it names the public origin. `not_found` answers (pages the plugin does not know: listings, search, author pages, static pages without a URL rule) leave the head to the page.
- **Robots and staging.** A `staging` answer (the backend outside `SUMMER_ENV=production`) makes every page `noindex, nofollow`, known or not. The `X-Robots-Tag` response header follows the same value.
- **404 log.** Each page view answered 404 (a GET that accepts `text/html`, from an unknown route or a page that throws a 404) is reported with `POST <apiBase>/notfound`, `{"url", "referer"}` and `Authorization: Bearer <notfoundToken>`, after the response. Without a token nothing is reported.
- **Verification files.** A request for a single-segment `.html`, `.txt` or `.xml` file (`/google0123.html`, `/BingSiteAuth.xml`) is looked up under `GET <apiBase>/verification/<name>` and served with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `X-Robots-Tag: noindex` when the plugin has it; otherwise it falls through to the app's routes. `robots.txt`, `sitemap.xml`, `sitemap.xsl`, `rss.xml`, `feed.xml`, `atom.xml` and `llms.txt` are never looked up.
@@ -42,7 +42,7 @@ The plugin serves `/robots.txt` itself; route that path to the backend (and turn
- `useSeoAnswer()`: the resolve answer of the current page (`SeoResolveAnswer | null`), shared state.
- `resolveSeo(url)`: one resolve call with the server or public base; `null` on any failure.
- `seoHeadInput`, `seoRedirectTarget`, `seoRobots`, `seoXRobotsTag`, `seoResolvableUrl`, `seoAnswer`, `seoVerificationName`: the pure helpers behind them (`app/utils/seo.ts`), auto-imported.
- `seoHeadInput`, `seoAbsolute`, `seoRedirectTarget`, `seoRobots`, `seoXRobotsTag`, `seoResolvableUrl`, `seoAnswer`, `seoVerificationName`: the pure helpers behind them (`app/utils/seo.ts`), auto-imported.
## Development

View File

@@ -10,5 +10,6 @@ export default defineNuxtPlugin(() => {
const config = useRuntimeConfig()
if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return
const answer = useSeoAnswer()
useHead(computed(() => seoHeadInput(answer.value)), { tagPriority: 'critical' })
const origin = useRequestURL().origin
useHead(computed(() => seoHeadInput(answer.value, origin)), { tagPriority: 'critical' })
})

View File

@@ -83,13 +83,26 @@ interface SeoHeadInput {
link: Array<{ rel: string; href: string }>
}
/**
* `value` made absolute on `origin` when it is a path ("/x"); anything else
* is returned as it is. The plugin answers paths when the backend has no
* site URL (golem15.seo.site_url, app.url), and a canonical or social URL
* must be absolute.
*/
export function seoAbsolute(value: string, origin: string | undefined): string {
if (!origin || !value.startsWith('/') || value.startsWith('//')) return value
return origin.replace(/\/+$/, '') + value
}
/**
* The head of an answer. Only non-empty values become tags, so the page's
* own tag stays wherever the answer has none. The title is final (the
* plugin applies its templates), hence the plain titleTemplate. A not_found
* answer, or no answer, contributes only the staging robots.
* answer, or no answer, contributes only the staging robots. A canonical,
* og:url or og:image that is a path is made absolute on `origin` (the
* request's origin).
*/
export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
export function seoHeadInput(answer: SeoResolveAnswer | null, origin?: string): SeoHeadInput {
const head: SeoHeadInput = { meta: [], link: [] }
const robots = seoRobots(answer)
if (robots) head.meta.push({ name: 'robots', content: robots })
@@ -108,8 +121,8 @@ export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
name('description', seo.description)
property('og:title', seo.og?.title)
property('og:description', seo.og?.description)
property('og:image', seo.og?.image)
property('og:url', seo.og?.url)
property('og:image', seo.og?.image && seoAbsolute(seo.og.image, origin))
property('og:url', seo.og?.url && seoAbsolute(seo.og.url, origin))
name('twitter:card', seo.twitter?.card)
name('twitter:site', seo.twitter?.site)
for (const tag of Array.isArray(seo.verification) ? seo.verification : []) {
@@ -117,7 +130,7 @@ export function seoHeadInput(answer: SeoResolveAnswer | null): SeoHeadInput {
head.meta.push({ name: tag.name, content: tag.content })
}
}
if (seo.canonical) head.link.push({ rel: 'canonical', href: seo.canonical })
if (seo.canonical) head.link.push({ rel: 'canonical', href: seoAbsolute(seo.canonical, origin) })
return head
}

View File

@@ -1,6 +1,6 @@
{
"name": "vue-seo-layer",
"version": "0.1.0",
"version": "0.1.1",
"private": true,
"type": "module",
"main": "./nuxt.config.ts",

View File

@@ -49,3 +49,8 @@ report /posts/nope
200 /google0123.html contains=google-site-verification: google0123.html header=X-Content-Type-Options: nosniff
404 /unknown0123.html
404 /robots.txt
# A backend without a site URL answers paths: they become absolute on the
# request's origin.
200 /relative contains=<link rel="canonical" href="http://127.0.0.1:
200 /relative contains=/img/rel.png"> notcontains=content="/img/rel.png"

View File

@@ -1,38 +1,158 @@
{
"/": {
"action": "ok", "status": 200, "url": "https://acme.test/", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/",
"locale": "en",
"staging": false,
"seo": {
"title": "Home | Acme", "description": "Acme home", "canonical": "https://acme.test/",
"robots": "index, follow", "x_robots_tag": "index, follow",
"og": { "title": "Home | Acme", "description": "Acme home", "image": "https://acme.test/home.png", "url": "https://acme.test/" },
"twitter": { "card": "summary_large_image", "site": "@acme" },
"title": "Home | Acme",
"description": "Acme home",
"canonical": "https://acme.test/",
"robots": "index, follow",
"x_robots_tag": "index, follow",
"og": {
"title": "Home | Acme",
"description": "Acme home",
"image": "https://acme.test/home.png",
"url": "https://acme.test/"
},
"twitter": {
"card": "summary_large_image",
"site": "@acme"
},
"verification": []
}
},
"/about": {
"action": "ok", "status": 200, "url": "https://acme.test/about", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/about",
"locale": "en",
"staging": false,
"seo": {
"title": "About us | Acme", "description": "Who we are", "canonical": "https://acme.test/about",
"robots": "index, follow, max-image-preview:large", "x_robots_tag": "index, follow, max-image-preview:large",
"og": { "title": "About us | Acme", "description": "Who we are", "image": "", "url": "https://acme.test/about" },
"twitter": { "card": "summary", "site": "@acme" },
"verification": [{ "name": "google-site-verification", "content": "fixture-google-code" }]
"title": "About us | Acme",
"description": "Who we are",
"canonical": "https://acme.test/about",
"robots": "index, follow, max-image-preview:large",
"x_robots_tag": "index, follow, max-image-preview:large",
"og": {
"title": "About us | Acme",
"description": "Who we are",
"image": "",
"url": "https://acme.test/about"
},
"twitter": {
"card": "summary",
"site": "@acme"
},
"verification": [
{
"name": "google-site-verification",
"content": "fixture-google-code"
}
]
}
},
"/partial": {
"action": "ok", "status": 200, "url": "https://acme.test/partial", "locale": "en", "staging": false,
"action": "ok",
"status": 200,
"url": "https://acme.test/partial",
"locale": "en",
"staging": false,
"seo": {
"title": "", "description": "", "canonical": "https://acme.test/partial",
"robots": "", "x_robots_tag": "",
"og": { "title": "", "description": "", "image": "", "url": "" },
"twitter": { "card": "", "site": "" },
"title": "",
"description": "",
"canonical": "https://acme.test/partial",
"robots": "",
"x_robots_tag": "",
"og": {
"title": "",
"description": "",
"image": "",
"url": ""
},
"twitter": {
"card": "",
"site": ""
},
"verification": []
}
},
"/staging": { "action": "not_found", "status": 404, "url": "https://acme.test/staging", "locale": "", "staging": true, "seo": null },
"/old-path": { "action": "redirect", "status": 301, "location": "https://acme.test/about", "url": "https://acme.test/old-path", "locale": "", "staging": false, "seo": null },
"/old-temp?ref=1": { "action": "redirect", "status": 302, "location": "https://acme.test/about?x=1", "url": "https://acme.test/old-temp?ref=1", "locale": "", "staging": false, "seo": null },
"/moved": { "action": "redirect", "status": 301, "location": "https://other.test/x", "url": "https://acme.test/moved", "locale": "", "staging": false, "seo": null },
"/gone-path": { "action": "gone", "status": 410, "url": "https://acme.test/gone-path", "locale": "", "staging": false, "seo": null },
"/posts/gone-post": { "action": "gone", "status": 410, "url": "https://acme.test/posts/gone-post", "locale": "", "staging": false, "seo": null }
"/staging": {
"action": "not_found",
"status": 404,
"url": "https://acme.test/staging",
"locale": "",
"staging": true,
"seo": null
},
"/old-path": {
"action": "redirect",
"status": 301,
"location": "https://acme.test/about",
"url": "https://acme.test/old-path",
"locale": "",
"staging": false,
"seo": null
},
"/old-temp?ref=1": {
"action": "redirect",
"status": 302,
"location": "https://acme.test/about?x=1",
"url": "https://acme.test/old-temp?ref=1",
"locale": "",
"staging": false,
"seo": null
},
"/moved": {
"action": "redirect",
"status": 301,
"location": "https://other.test/x",
"url": "https://acme.test/moved",
"locale": "",
"staging": false,
"seo": null
},
"/gone-path": {
"action": "gone",
"status": 410,
"url": "https://acme.test/gone-path",
"locale": "",
"staging": false,
"seo": null
},
"/posts/gone-post": {
"action": "gone",
"status": 410,
"url": "https://acme.test/posts/gone-post",
"locale": "",
"staging": false,
"seo": null
},
"/relative": {
"action": "ok",
"status": 200,
"url": "/relative",
"locale": "en",
"staging": false,
"seo": {
"title": "Relative | Acme",
"description": "",
"canonical": "/relative",
"robots": "",
"x_robots_tag": "",
"og": {
"title": "",
"description": "",
"image": "/img/rel.png",
"url": "/relative"
},
"twitter": {
"card": "",
"site": ""
},
"verification": []
}
}
}

View File

@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import {
SEO_STAGING_ROBOTS,
seoAbsolute,
seoAnswer,
seoHeadInput,
seoRedirectTarget,
@@ -108,3 +109,16 @@ test('verification names: one segment, three extensions, never a reserved name',
assert.equal(seoVerificationName(p, reserved), null, p)
}
})
test('paths in canonical and social URLs become absolute on the request origin', () => {
assert.equal(seoAbsolute('/a?b=1', 'https://acme.test'), 'https://acme.test/a?b=1')
assert.equal(seoAbsolute('/a', 'https://acme.test/'), 'https://acme.test/a')
assert.equal(seoAbsolute('https://x.test/a', 'https://acme.test'), 'https://x.test/a')
assert.equal(seoAbsolute('//x.test/a', 'https://acme.test'), '//x.test/a')
assert.equal(seoAbsolute('/a', undefined), '/a')
const head = seoHeadInput(okAnswer({ canonical: '/about', og: { title: '', description: '', image: '/img.png', url: '/about' } }), 'https://site.test')
assert.deepEqual(head.link, [{ rel: 'canonical', href: 'https://site.test/about' }])
const meta = Object.fromEntries(head.meta.map((m) => [m.name ?? m.property, m.content]))
assert.equal(meta['og:image'], 'https://site.test/img.png')
assert.equal(meta['og:url'], 'https://site.test/about')
})