From 7d52c5d2e50fb5870887751adfdcd5e78512a271 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 31 Oct 2026 13:53:30 +0100 Subject: [PATCH] fix: canonical and social URLs answered as paths become absolute on the request origin --- .playground/app/pages/relative.vue | 7 ++ README.md | 6 +- app/plugins/seo-head.ts | 3 +- app/utils/seo.ts | 23 +++- package.json | 2 +- testing/expect.txt | 5 + testing/fixtures/resolve.json | 164 +++++++++++++++++++++++++---- tests/seo.test.ts | 14 +++ 8 files changed, 192 insertions(+), 32 deletions(-) create mode 100644 .playground/app/pages/relative.vue diff --git a/.playground/app/pages/relative.vue b/.playground/app/pages/relative.vue new file mode 100644 index 0000000..70e5d48 --- /dev/null +++ b/.playground/app/pages/relative.vue @@ -0,0 +1,7 @@ + + + diff --git a/README.md b/README.md index c683bea..79bf098 100644 --- a/README.md +++ b/README.md @@ -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 /resolve?url=` 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 /notfound`, `{"url", "referer"}` and `Authorization: Bearer `, 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 /verification/` 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 diff --git a/app/plugins/seo-head.ts b/app/plugins/seo-head.ts index be3de4f..06898df 100644 --- a/app/plugins/seo-head.ts +++ b/app/plugins/seo-head.ts @@ -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' }) }) diff --git a/app/utils/seo.ts b/app/utils/seo.ts index daaf8b5..3d90e41 100644 --- a/app/utils/seo.ts +++ b/app/utils/seo.ts @@ -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 } diff --git a/package.json b/package.json index aa0b911..818b3d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vue-seo-layer", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "main": "./nuxt.config.ts", diff --git a/testing/expect.txt b/testing/expect.txt index a5e29a4..7a106b7 100644 --- a/testing/expect.txt +++ b/testing/expect.txt @@ -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= notcontains=content="/img/rel.png" diff --git a/testing/fixtures/resolve.json b/testing/fixtures/resolve.json index c084bac..5d15e1b 100644 --- a/testing/fixtures/resolve.json +++ b/testing/fixtures/resolve.json @@ -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": [] + } + } } diff --git a/tests/seo.test.ts b/tests/seo.test.ts index c3579cf..e731718 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -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') +})