Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d52c5d2e5 |
7
.playground/app/pages/relative.vue
Normal file
7
.playground/app/pages/relative.vue
Normal file
@@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
useSeoMeta({ title: 'Relative own' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main><h1>Relative</h1></main>
|
||||
</template>
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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' })
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "vue-seo-layer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "./nuxt.config.ts",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user