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 @@
+
+
+
+ Relative
+
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')
+})