@octanejs/seo 0.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/package.json +42 -0
- package/src/components.tsrx +217 -0
- package/src/components.tsrx.d.ts +17 -0
- package/src/context.ts +4 -0
- package/src/descriptors.ts +323 -0
- package/src/expand.ts +262 -0
- package/src/index.ts +31 -0
- package/src/registry.ts +125 -0
- package/src/useRegister.ts +55 -0
- package/src/useStrayOwnerDiagnostic.ts +42 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# `@octanejs/seo`
|
|
2
|
+
|
|
3
|
+
Declarative document metadata for Octane: server-rendered into `<head>`, adopted
|
|
4
|
+
on hydration, and merged so the most specific declaration wins.
|
|
5
|
+
|
|
6
|
+
```tsx
|
|
7
|
+
import { Head, Link, Meta, Script, Title } from '@octanejs/seo';
|
|
8
|
+
|
|
9
|
+
function App() @{
|
|
10
|
+
<Head>
|
|
11
|
+
<Head>
|
|
12
|
+
<Title text="Acme" />
|
|
13
|
+
<Meta name="description" content="Widgets for everyone" />
|
|
14
|
+
</Head>
|
|
15
|
+
<Router />
|
|
16
|
+
</Head>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ProductPage(props: { product: Product }) @{
|
|
20
|
+
<>
|
|
21
|
+
<Head>
|
|
22
|
+
<Title text={props.product.name} />
|
|
23
|
+
<Meta name="description" content={props.product.blurb} />
|
|
24
|
+
<Link rel="canonical" href={'/p/' + props.product.slug} />
|
|
25
|
+
<Script type="application/ld+json" json={{ '@type': 'Product', name: props.product.name }} />
|
|
26
|
+
</Head>
|
|
27
|
+
<main>…</main>
|
|
28
|
+
</>
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The product page's title and description replace the app-level ones. Everything
|
|
33
|
+
else the app declared stays.
|
|
34
|
+
|
|
35
|
+
## Why a merge exists
|
|
36
|
+
|
|
37
|
+
The platform resolves duplicates by taking the **first** in tree order:
|
|
38
|
+
`document.title` is defined as the first `<title>` element in the document, and
|
|
39
|
+
a crawler reads the first `meta[name="description"]`. Authoring order runs the
|
|
40
|
+
other way, with app defaults written before page specifics, so simply emitting
|
|
41
|
+
both would let the generic value win every time. Registrations are therefore
|
|
42
|
+
keyed by identity and the **last** one wins.
|
|
43
|
+
|
|
44
|
+
Identity is what the tag names, not the tag type. `meta[name]`,
|
|
45
|
+
`meta[property]`, and `meta[http-equiv]` are separate channels. JSON-LD is keyed
|
|
46
|
+
by `@type` (plus `@id`), so an `Article` replaces an `Article` while a
|
|
47
|
+
`BreadcrumbList` sits alongside it.
|
|
48
|
+
|
|
49
|
+
`<link>` needs three rules, because `href` is sometimes the value being set and
|
|
50
|
+
sometimes the thing being identified:
|
|
51
|
+
|
|
52
|
+
| rel | identity | effect |
|
|
53
|
+
| --- | --- | --- |
|
|
54
|
+
| `canonical`, `manifest`, `author`, `license`, `prev`, `next` | `rel` alone | one per document |
|
|
55
|
+
| `alternate` (`hreflang`/`type`/`media`/`title`), `icon` and `apple-touch-icon` (`sizes`/`type`), `mask-icon`, `search` | the named slot, **not** `href` | a page moving the German alternate or the 32×32 icon replaces it |
|
|
56
|
+
| everything else, including `preload`, `prefetch`, `preconnect`, `modulepreload`, `stylesheet`, and any rel not listed above | the target URL | two font preloads or two stylesheets coexist |
|
|
57
|
+
|
|
58
|
+
Unknown rels fall in the last group deliberately: emitting two tags is a smaller
|
|
59
|
+
mistake than silently dropping one.
|
|
60
|
+
|
|
61
|
+
## `<Head>`, and where to put it
|
|
62
|
+
|
|
63
|
+
Wrap the app in one:
|
|
64
|
+
|
|
65
|
+
```tsx
|
|
66
|
+
<Head>
|
|
67
|
+
<App />
|
|
68
|
+
</Head>
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Then use `<Head>` again wherever metadata belongs. **Position carries no
|
|
72
|
+
meaning.** Two blocks merge whether one contains the other or they sit in
|
|
73
|
+
unrelated components, and precedence never depends on nesting depth: the last
|
|
74
|
+
registration of a given identity wins, so a page overrides a layout simply by
|
|
75
|
+
rendering later. Tags written bare under the outer `<Head>`, with no block around
|
|
76
|
+
them, behave identically.
|
|
77
|
+
|
|
78
|
+
The outermost `<Head>` is what makes that true. The merge has to see every
|
|
79
|
+
registration before it emits anything, and a string renderer emits in document
|
|
80
|
+
order, so blocks that owned their own metadata would each emit a set and the
|
|
81
|
+
platform's first-wins rule would hand the page to whichever rendered first. This
|
|
82
|
+
would then quietly break:
|
|
83
|
+
|
|
84
|
+
```tsx
|
|
85
|
+
function Page() @{
|
|
86
|
+
<>
|
|
87
|
+
<Head><Title text="Listing" /></Head>
|
|
88
|
+
<Detail /> {/* its own <Head> is a SIBLING */}
|
|
89
|
+
</>
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
With an outer `<Head>` around the app, `<Detail>` wins as written. A tag with no
|
|
94
|
+
`<Head>` above it throws, and two `<Head>` elements where neither contains the
|
|
95
|
+
other are reported in development.
|
|
96
|
+
|
|
97
|
+
## Components
|
|
98
|
+
|
|
99
|
+
| Component | Purpose |
|
|
100
|
+
| --- | --- |
|
|
101
|
+
| `<Title text="…" />` | Document title |
|
|
102
|
+
| `<Meta name/property/http-equiv … />` | Any meta tag |
|
|
103
|
+
| `<Link rel="…" href="…" />` | canonical, alternate, icon, manifest |
|
|
104
|
+
| `<Script type json / text />` | JSON-LD and other head scripts |
|
|
105
|
+
| `<Seo … />` | The whole metadata object at once |
|
|
106
|
+
|
|
107
|
+
`<Title>` takes its text as a **prop**, not JSX children. Element children
|
|
108
|
+
compile to a children block (a function), and coercing one to a string would put
|
|
109
|
+
source code in the document title, so that case throws instead.
|
|
110
|
+
|
|
111
|
+
`<Seo>` is the object form and expands to the tags above:
|
|
112
|
+
|
|
113
|
+
```tsx
|
|
114
|
+
<Seo
|
|
115
|
+
title="Post title"
|
|
116
|
+
description="Post summary"
|
|
117
|
+
canonical="/blog/post"
|
|
118
|
+
site="https://example.com"
|
|
119
|
+
titleTemplate="%s · Example"
|
|
120
|
+
openGraph={{ type: 'article', images: [{ url: '/og.png', alt: 'Post', width: 1200, height: 630 }] }}
|
|
121
|
+
twitter={{ card: 'summary_large_image', site: '@example' }}
|
|
122
|
+
languages={{ de: '/de/blog/post', 'x-default': '/blog/post' }}
|
|
123
|
+
robots={{ index: true, follow: true, maxImagePreview: 'large' }}
|
|
124
|
+
jsonLd={{ '@type': 'Article', headline: 'Post title' }}
|
|
125
|
+
/>
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## App-level settings
|
|
129
|
+
|
|
130
|
+
Three things are declared once and apply everywhere, because the component that
|
|
131
|
+
knows them is rarely the one that renders a page:
|
|
132
|
+
|
|
133
|
+
- **`site`** absolute-ises the URLs a consumer reads without a base: `canonical`,
|
|
134
|
+
`link rel="alternate"` hreflang addresses, `og:url`, `og:image`, and
|
|
135
|
+
`twitter:image`. It deliberately does **not** touch subresources the browser
|
|
136
|
+
fetches, so `preload`, `prefetch`, `modulepreload`, `stylesheet`, `icon`, and
|
|
137
|
+
`manifest` keep resolving against the document actually serving the response.
|
|
138
|
+
Rewriting those would make a preview deploy carrying the production `site` pull
|
|
139
|
+
fonts, CSS, and modules from production.
|
|
140
|
+
- **`titleTemplate`** wraps each page's title, so `%s · Acme` applies to a page
|
|
141
|
+
that only sets `title: 'Pricing'`.
|
|
142
|
+
- **The Open Graph and Twitter shell.** Declare `openGraph`/`twitter` once and
|
|
143
|
+
`og:title`, `og:description`, `og:url`, `twitter:title`, and
|
|
144
|
+
`twitter:description` are mirrored from whatever page renders, unless that page
|
|
145
|
+
names them itself. Only families you actually declared are filled, so an app
|
|
146
|
+
that never asked for Open Graph never emits it.
|
|
147
|
+
|
|
148
|
+
```tsx
|
|
149
|
+
// once, near the root
|
|
150
|
+
<Seo
|
|
151
|
+
site="https://example.com"
|
|
152
|
+
titleTemplate="%s · Example"
|
|
153
|
+
openGraph={{ type: 'website', siteName: 'Example' }}
|
|
154
|
+
twitter={{ card: 'summary_large_image' }}
|
|
155
|
+
/>
|
|
156
|
+
|
|
157
|
+
// and in a page, anywhere below
|
|
158
|
+
<Seo title="Pricing" description="Plans and limits." canonical="/pricing" />
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
All three are applied after the merge, once the whole tree has registered. The
|
|
162
|
+
social mirror uses the raw title rather than the templated one, since
|
|
163
|
+
`og:site_name` already carries what a template adds.
|
|
164
|
+
|
|
165
|
+
## Server rendering
|
|
166
|
+
|
|
167
|
+
Metadata registered during render reaches `<head>` in the served HTML, which is
|
|
168
|
+
the point: an effect-based approach never runs on the server, so crawlers would
|
|
169
|
+
see nothing. Under `@octanejs/vite-plugin` this works with no configuration.
|
|
170
|
+
|
|
171
|
+
For a custom server, render with `headChannel: 'separate'` and splice the
|
|
172
|
+
returned metadata into your template's `<head>`:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const { html, css, head } = await prerender(App, props, { headChannel: 'separate' });
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
Streaming uses `onHeadReady(head)`, which fires before the shell is written. See
|
|
179
|
+
`docs/ssr.md`.
|
|
180
|
+
|
|
181
|
+
Two caveats worth knowing:
|
|
182
|
+
|
|
183
|
+
- **Remove any static `<title>` from `index.html`.** The hoisted metadata is
|
|
184
|
+
spliced at `<!--ssr-head-->`, after it, so a template title would win.
|
|
185
|
+
- **Metadata that depends on suspended data does not reach a streamed shell.**
|
|
186
|
+
The shell flushes before the data settles. Derive metadata from data you
|
|
187
|
+
already have, or use the buffered renderer for those routes.
|
|
188
|
+
|
|
189
|
+
## Hydration and navigation
|
|
190
|
+
|
|
191
|
+
The client adopts the server's elements instead of appending its own, updates
|
|
192
|
+
them in place rather than replacing them (a swapped `<link>` would re-fetch its
|
|
193
|
+
resource), and removes what it owns when a page unmounts, so navigating between
|
|
194
|
+
routes never accumulates stale canonicals or `og:image` tags.
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/seo",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"description": "SEO metadata for Octane, declarative <Title>/<Meta>/<Link>/<JsonLd> with last-wins merging, server-rendered into <head> and adopted on hydration.",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Dominic Gannaway",
|
|
12
|
+
"email": "dg@domgan.com"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
20
|
+
"directory": "packages/seo"
|
|
21
|
+
},
|
|
22
|
+
"main": "src/index.ts",
|
|
23
|
+
"module": "src/index.ts",
|
|
24
|
+
"types": "src/index.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"octane": "0.1.17"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"vitest": "^4.1.10",
|
|
37
|
+
"octane": "0.1.17"
|
|
38
|
+
},
|
|
39
|
+
"scripts": {
|
|
40
|
+
"test": "vitest run"
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { useContext, useRef, useSyncExternalStore } from 'octane';
|
|
2
|
+
import type { OctaneNode } from 'octane';
|
|
3
|
+
import { SeoContext } from './context.ts';
|
|
4
|
+
import { createSeoRegistry, type SeoRegistry } from './registry.ts';
|
|
5
|
+
import { linkKey, metaKey, type MetaAttributes, type SeoDescriptor } from './descriptors.ts';
|
|
6
|
+
import { expandSeo, jsonLdDescriptor, type SeoInput } from './expand.ts';
|
|
7
|
+
import { useRegisterSeo } from './useRegister.ts';
|
|
8
|
+
import { useStrayOwnerDiagnostic } from './useStrayOwnerDiagnostic.ts';
|
|
9
|
+
|
|
10
|
+
// Shared so a dropped descriptor registers a stable empty set rather than a new
|
|
11
|
+
// array each render, which would invalidate the merge every time.
|
|
12
|
+
const EMPTY: readonly SeoDescriptor[] = [];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One rendered head element.
|
|
16
|
+
*
|
|
17
|
+
* `<title>`/`<meta>`/`<link>` are hoisted by the compiler into the render's head
|
|
18
|
+
* channel, so they reach the real `<head>` and are adopted on hydration rather
|
|
19
|
+
* than recreated. `<script type="application/ld+json">` is NOT hoistable and
|
|
20
|
+
* stays where it renders, which is valid: JSON-LD is explicitly supported in the
|
|
21
|
+
* body. Its content goes through `dangerouslySetInnerHTML` because `<script>` is
|
|
22
|
+
* a raw-text element; the renderer escapes inline script content itself.
|
|
23
|
+
*/
|
|
24
|
+
function SeoTag(props: { descriptor: SeoDescriptor }) @{
|
|
25
|
+
@switch (props.descriptor.tag) {
|
|
26
|
+
@case 'title': {
|
|
27
|
+
<title>{(props.descriptor.text ?? '') as string}</title>
|
|
28
|
+
}
|
|
29
|
+
@case 'meta': {
|
|
30
|
+
<meta {...props.descriptor.attrs} />
|
|
31
|
+
}
|
|
32
|
+
@case 'link': {
|
|
33
|
+
<link {...props.descriptor.attrs} />
|
|
34
|
+
}
|
|
35
|
+
@default: {
|
|
36
|
+
<script
|
|
37
|
+
{...props.descriptor.attrs}
|
|
38
|
+
dangerouslySetInnerHTML={{ __html: props.descriptor.text ?? '' }}
|
|
39
|
+
/>
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Emits the merged metadata. An owning `<Head>` renders this AFTER its children,
|
|
46
|
+
* so every descendant registration is already in the registry by the time this
|
|
47
|
+
* reads it, that ordering is what makes an inner page's title beat an outer
|
|
48
|
+
* layout's, which the platform's own first-wins rule would otherwise invert.
|
|
49
|
+
*/
|
|
50
|
+
function SeoOutlet() @{
|
|
51
|
+
const registry = useContext(SeoContext);
|
|
52
|
+
if (registry === null) {
|
|
53
|
+
throw new Error('[@octanejs/seo] internal: <SeoOutlet> rendered with no registry.');
|
|
54
|
+
}
|
|
55
|
+
const descriptors = useSyncExternalStore(
|
|
56
|
+
registry.subscribe,
|
|
57
|
+
registry.getSnapshot,
|
|
58
|
+
registry.getSnapshot,
|
|
59
|
+
);
|
|
60
|
+
<>
|
|
61
|
+
@for (const descriptor of descriptors; key descriptor.key) {
|
|
62
|
+
<SeoTag descriptor={descriptor} />
|
|
63
|
+
}
|
|
64
|
+
</>
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* `<Title text="Page name" />`, the innermost one rendered wins.
|
|
69
|
+
*
|
|
70
|
+
* The text is a prop, not JSX children, and deliberately so: the compiler turns
|
|
71
|
+
* element children into a children block (a function), and coercing one to a
|
|
72
|
+
* string would silently put source code in the document title. `children` is
|
|
73
|
+
* accepted in its string prop form for convenience; anything else is rejected
|
|
74
|
+
* loudly rather than serialized.
|
|
75
|
+
*/
|
|
76
|
+
export function Title(props: { text?: string; children?: string }) {
|
|
77
|
+
useRegisterSeo([
|
|
78
|
+
{ tag: 'title', key: 'title', attrs: {}, text: titleText(props) },
|
|
79
|
+
]);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function titleText(props: { text?: string; children?: string }): string {
|
|
84
|
+
const value = props.text ?? props.children;
|
|
85
|
+
if (typeof value === 'string') return value;
|
|
86
|
+
if (typeof value === 'number') return String(value);
|
|
87
|
+
if (value === undefined || value === null) return '';
|
|
88
|
+
throw new Error('[@octanejs/seo] <Title> needs a string: use <Title text="…" /> or <Seo title="…" />. ' +
|
|
89
|
+
'JSX children compile to a children block, which is not a string.');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `<Meta name="description" content="…" />` / `<Meta property="og:title" … />`. */
|
|
93
|
+
export function Meta(props: MetaAttributes & { seoKey?: string }) {
|
|
94
|
+
const { seoKey, ...attrs } = props;
|
|
95
|
+
useRegisterSeo([
|
|
96
|
+
{ tag: 'meta', key: seoKey ?? metaKey(attrs), attrs },
|
|
97
|
+
]);
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** `<Link rel="canonical" href="…" />`. */
|
|
102
|
+
export function Link(props: MetaAttributes & { seoKey?: string }) {
|
|
103
|
+
const { seoKey, ...attrs } = props;
|
|
104
|
+
useRegisterSeo([
|
|
105
|
+
{ tag: 'link', key: seoKey ?? linkKey(attrs), attrs },
|
|
106
|
+
]);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The one component. Wrap the app in it once, then use it again wherever
|
|
112
|
+
* metadata belongs:
|
|
113
|
+
*
|
|
114
|
+
* ```tsx
|
|
115
|
+
* <Head>
|
|
116
|
+
* <App />
|
|
117
|
+
* </Head>
|
|
118
|
+
*
|
|
119
|
+
* // …anywhere below, in any component
|
|
120
|
+
* <Head>
|
|
121
|
+
* <Title text="Post title" />
|
|
122
|
+
* <Meta name="description" content="…" />
|
|
123
|
+
* <Link rel="canonical" href="/post" />
|
|
124
|
+
* <Script type="application/ld+json" json={article} />
|
|
125
|
+
* </Head>
|
|
126
|
+
* ```
|
|
127
|
+
*
|
|
128
|
+
* The OUTERMOST one owns the metadata: it holds the registry and renders the
|
|
129
|
+
* merged tags after its children. Every `<Head>` below it is grouping only, and
|
|
130
|
+
* where those blocks sit carries no meaning. Two of them merge whether one
|
|
131
|
+
* contains the other or they are siblings in unrelated components, and
|
|
132
|
+
* precedence does not depend on nesting depth: the LAST registration of a given
|
|
133
|
+
* identity wins, so a page overrides a layout simply by rendering later.
|
|
134
|
+
*
|
|
135
|
+
* That is why the outer one has to exist. The merge must see every registration
|
|
136
|
+
* before it emits anything, and a string renderer emits in document order, so
|
|
137
|
+
* blocks that owned their own metadata would each emit a set and the platform's
|
|
138
|
+
* first-wins rule would hand the page to whichever rendered first.
|
|
139
|
+
*
|
|
140
|
+
* The registry lives here rather than in module scope, so concurrent server
|
|
141
|
+
* renders cannot see each other's metadata.
|
|
142
|
+
*/
|
|
143
|
+
export function Head(props: { children?: OctaneNode }) @{
|
|
144
|
+
const outer = useContext(SeoContext);
|
|
145
|
+
const registryRef = useRef<SeoRegistry | null>(null);
|
|
146
|
+
if (outer === null && registryRef.current === null) registryRef.current = createSeoRegistry();
|
|
147
|
+
useStrayOwnerDiagnostic(outer === null);
|
|
148
|
+
@if (outer === null) {
|
|
149
|
+
<SeoContext.Provider value={registryRef.current}>
|
|
150
|
+
{props.children}
|
|
151
|
+
<SeoOutlet />
|
|
152
|
+
</SeoContext.Provider>
|
|
153
|
+
} @else {
|
|
154
|
+
<>
|
|
155
|
+
{props.children}
|
|
156
|
+
</>
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A head script, typically JSON-LD.
|
|
162
|
+
*
|
|
163
|
+
* Pass `json` for structured data (serialized for you) or `text` for a raw body.
|
|
164
|
+
* Unlike `<title>`/`<meta>`/`<link>`, a `<script>` is not hoisted into `<head>`
|
|
165
|
+
* and renders where the outlet sits, which is valid: JSON-LD is explicitly
|
|
166
|
+
* supported in the body. The renderer escapes inline script content.
|
|
167
|
+
*/
|
|
168
|
+
export function Script(props: {
|
|
169
|
+
type?: string;
|
|
170
|
+
json?: unknown;
|
|
171
|
+
text?: string;
|
|
172
|
+
seoKey?: string;
|
|
173
|
+
[attr: string]: unknown;
|
|
174
|
+
}) {
|
|
175
|
+
const { json, text, seoKey, ...attrs } = props;
|
|
176
|
+
const type =
|
|
177
|
+
typeof attrs.type === 'string' ? attrs.type : 'application/ld+json';
|
|
178
|
+
// ONE registration call site. Hooks are keyed by call site, so branching into
|
|
179
|
+
// two `useRegisterSeo` calls would give a `<Script>` that switches between
|
|
180
|
+
// `json` and `text` a second slot, and the abandoned branch's source would
|
|
181
|
+
// stay registered, a stale script no unmount ever removes.
|
|
182
|
+
const descriptor: SeoDescriptor | null =
|
|
183
|
+
json !== undefined
|
|
184
|
+
? jsonLdDescriptor(json, seoKey)
|
|
185
|
+
: {
|
|
186
|
+
tag: 'script',
|
|
187
|
+
key: 'script:' + (seoKey ?? type),
|
|
188
|
+
attrs: { ...attrs as MetaAttributes, type },
|
|
189
|
+
text: text ?? '',
|
|
190
|
+
};
|
|
191
|
+
useRegisterSeo(descriptor === null ? EMPTY : [descriptor]);
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Structured data. Identity is `@type` (plus `@id`) unless `seoKey` is given. */
|
|
196
|
+
export function JsonLd(props: { data: unknown; seoKey?: string }) {
|
|
197
|
+
const descriptor = jsonLdDescriptor(props.data, props.seoKey);
|
|
198
|
+
useRegisterSeo(descriptor === null ? EMPTY : [descriptor]);
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The whole metadata object in one element; expands to the tags above.
|
|
204
|
+
*
|
|
205
|
+
* `site` and `titleTemplate` are app-level: they merge last-wins like everything
|
|
206
|
+
* else and apply to every page's title and URLs, so setting them once near the
|
|
207
|
+
* root is enough. A page that names its own still overrides them.
|
|
208
|
+
*/
|
|
209
|
+
export function Seo(props: SeoInput) {
|
|
210
|
+
useRegisterSeo(expandSeo(props), {
|
|
211
|
+
site: props.site,
|
|
212
|
+
titleTemplate: props.titleTemplate,
|
|
213
|
+
declaredOpenGraph: props.openGraph !== undefined,
|
|
214
|
+
declaredTwitter: props.twitter !== undefined,
|
|
215
|
+
});
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ComponentBody, OctaneNode } from 'octane';
|
|
2
|
+
import type { MetaAttributes } from './descriptors.js';
|
|
3
|
+
import type { SeoInput } from './expand.js';
|
|
4
|
+
|
|
5
|
+
export declare const Head: ComponentBody<{ children?: OctaneNode }>;
|
|
6
|
+
export declare const Title: ComponentBody<{ text?: string; children?: string }>;
|
|
7
|
+
export declare const Meta: ComponentBody<MetaAttributes & { seoKey?: string }>;
|
|
8
|
+
export declare const Link: ComponentBody<MetaAttributes & { seoKey?: string }>;
|
|
9
|
+
export declare const Script: ComponentBody<{
|
|
10
|
+
type?: string;
|
|
11
|
+
json?: unknown;
|
|
12
|
+
text?: string;
|
|
13
|
+
seoKey?: string;
|
|
14
|
+
[attr: string]: unknown;
|
|
15
|
+
}>;
|
|
16
|
+
export declare const JsonLd: ComponentBody<{ data: unknown; seoKey?: string }>;
|
|
17
|
+
export declare const Seo: ComponentBody<SeoInput>;
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Metadata descriptors and the merge that decides precedence.
|
|
3
|
+
*
|
|
4
|
+
* Renderer-free on purpose: server and client both merge with this exact code,
|
|
5
|
+
* so the two emit the identical set and hydration adopts instead of duplicating.
|
|
6
|
+
*
|
|
7
|
+
* WHY A MERGE EXISTS AT ALL. The platform resolves duplicates by taking the
|
|
8
|
+
* FIRST occurrence in tree order, `document.title` is defined as the first
|
|
9
|
+
* `<title>` element in the document, and a crawler reads the first
|
|
10
|
+
* `<meta name="description">`. Authoring order runs the other way: defaults come
|
|
11
|
+
* from the outer layout and the specific value from the inner page, so appending
|
|
12
|
+
* both would let the generic one win every time. Registrations are therefore
|
|
13
|
+
* keyed by identity and the LAST one wins, which is the precedence every SEO
|
|
14
|
+
* system uses and the one authors expect.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export type MetaAttributes = Record<string, string | number | boolean | null | undefined>;
|
|
18
|
+
|
|
19
|
+
/** One head element to render, plus the identity that decides what it replaces. */
|
|
20
|
+
export interface SeoDescriptor {
|
|
21
|
+
tag: 'title' | 'meta' | 'link' | 'script';
|
|
22
|
+
/** Identity key: a later descriptor with the same key replaces this one. */
|
|
23
|
+
key: string;
|
|
24
|
+
attrs: MetaAttributes;
|
|
25
|
+
/** Text content, for `<title>` and for script bodies. */
|
|
26
|
+
text?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Settings that belong to the app rather than to one declaration. They are
|
|
31
|
+
* registered like any other metadata and merged last-wins, so setting them once
|
|
32
|
+
* near the root applies them to every page's title and URLs.
|
|
33
|
+
*/
|
|
34
|
+
export interface SeoConfig {
|
|
35
|
+
/** Origin used to absolute-ise canonical, og:url, and image URLs. */
|
|
36
|
+
site?: string;
|
|
37
|
+
/** `%s` is replaced by the page title, e.g. `'%s · Acme'`. */
|
|
38
|
+
titleTemplate?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Whether an `openGraph`/`twitter` block was declared. Opting in is about
|
|
41
|
+
* having asked for the family, not about which tags that produced:
|
|
42
|
+
* `openGraph: { publishedTime }` emits only `article:published_time`, which no
|
|
43
|
+
* `og:` prefix scan would recognise.
|
|
44
|
+
*/
|
|
45
|
+
declaredOpenGraph?: boolean;
|
|
46
|
+
declaredTwitter?: boolean;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `<link>` rels whose `href` is an ADDRESS rather than a subresource, so it
|
|
51
|
+
* belongs to the canonical site and must be absolute.
|
|
52
|
+
*
|
|
53
|
+
* Everything else a `<link>` can point at, resource hints, stylesheets, icons,
|
|
54
|
+
* the manifest, is fetched by the browser and has to resolve against the
|
|
55
|
+
* document actually serving the response. Rewriting those to `site` would make a
|
|
56
|
+
* preview or staging deploy pull fonts, CSS, and modules from production.
|
|
57
|
+
*/
|
|
58
|
+
const SITE_ABSOLUTE_LINK_RELS = new Set(['canonical', 'alternate']);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The attribute holding a URL that a consumer will not resolve relative to the
|
|
62
|
+
* page, so it is absolute-ised against the configured origin at emit time, once
|
|
63
|
+
* the whole tree's config is known. Scrapers fetch `og:image` and read `og:url`
|
|
64
|
+
* without a base, and crawlers expect absolute canonical and hreflang addresses.
|
|
65
|
+
*/
|
|
66
|
+
function urlAttribute(descriptor: SeoDescriptor): string | null {
|
|
67
|
+
if (descriptor.tag === 'link') {
|
|
68
|
+
const rel = descriptor.attrs.rel;
|
|
69
|
+
return typeof rel === 'string' && SITE_ABSOLUTE_LINK_RELS.has(rel) ? 'href' : null;
|
|
70
|
+
}
|
|
71
|
+
if (descriptor.tag !== 'meta') return null;
|
|
72
|
+
const property = descriptor.attrs.property;
|
|
73
|
+
if (property === 'og:url' || property === 'og:image') return 'content';
|
|
74
|
+
if (descriptor.attrs.name === 'twitter:image') return 'content';
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Social tags that mirror a page value when the author has opted into that
|
|
80
|
+
* family but has not named them. `og:type` without `og:title` produces a useless
|
|
81
|
+
* card, and the page that owns the title is usually not the component that
|
|
82
|
+
* declared the Open Graph shell.
|
|
83
|
+
*/
|
|
84
|
+
const SOCIAL_FILL: readonly {
|
|
85
|
+
readonly key: string;
|
|
86
|
+
readonly family: 'og' | 'twitter';
|
|
87
|
+
readonly attrs: (value: string) => MetaAttributes;
|
|
88
|
+
readonly source: 'title' | 'description' | 'canonical';
|
|
89
|
+
}[] = [
|
|
90
|
+
{
|
|
91
|
+
key: 'meta:property=og:title',
|
|
92
|
+
family: 'og',
|
|
93
|
+
attrs: (content) => ({ property: 'og:title', content }),
|
|
94
|
+
source: 'title',
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
key: 'meta:property=og:description',
|
|
98
|
+
family: 'og',
|
|
99
|
+
attrs: (content) => ({ property: 'og:description', content }),
|
|
100
|
+
source: 'description',
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
key: 'meta:property=og:url',
|
|
104
|
+
family: 'og',
|
|
105
|
+
attrs: (content) => ({ property: 'og:url', content }),
|
|
106
|
+
source: 'canonical',
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
key: 'meta:name=twitter:title',
|
|
110
|
+
family: 'twitter',
|
|
111
|
+
attrs: (content) => ({ name: 'twitter:title', content }),
|
|
112
|
+
source: 'title',
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
key: 'meta:name=twitter:description',
|
|
116
|
+
family: 'twitter',
|
|
117
|
+
attrs: (content) => ({ name: 'twitter:description', content }),
|
|
118
|
+
source: 'description',
|
|
119
|
+
},
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Finish the merged set: mirror page values into the social families the author
|
|
124
|
+
* opted into, apply the title template, and absolute-ise URLs.
|
|
125
|
+
*
|
|
126
|
+
* All three are deferred to emit time for the same reason: a page registers its
|
|
127
|
+
* title, description, and canonical without ever seeing the root's `site`,
|
|
128
|
+
* `titleTemplate`, or Open Graph shell, and the root registers its shell without
|
|
129
|
+
* seeing which page will render.
|
|
130
|
+
*
|
|
131
|
+
* Order matters, and this is the ONLY place the template is applied. The social
|
|
132
|
+
* fill reads the title before that happens, because `og:site_name` already
|
|
133
|
+
* carries the suffix a template adds; templating anywhere earlier would leak the
|
|
134
|
+
* suffix into `og:title`. URL resolution runs last so an `og:url` mirrored from
|
|
135
|
+
* the canonical is absolute-ised too.
|
|
136
|
+
*/
|
|
137
|
+
export function applyConfig(
|
|
138
|
+
descriptors: readonly SeoDescriptor[],
|
|
139
|
+
config: SeoConfig,
|
|
140
|
+
): SeoDescriptor[] {
|
|
141
|
+
const { site, titleTemplate } = config;
|
|
142
|
+
const present = new Set(descriptors.map((descriptor) => descriptor.key));
|
|
143
|
+
const sources = {
|
|
144
|
+
title: descriptors.find((d) => d.tag === 'title')?.text,
|
|
145
|
+
description: stringAttr(descriptors, 'meta:name=description', 'content'),
|
|
146
|
+
canonical: stringAttr(descriptors, 'link:canonical', 'href'),
|
|
147
|
+
};
|
|
148
|
+
// Opted in either by declaring the block through `<Seo>` or by hand-writing a
|
|
149
|
+
// tag of that family. Nobody who never asked for Open Graph starts emitting it.
|
|
150
|
+
const optedIn = {
|
|
151
|
+
og: config.declaredOpenGraph === true,
|
|
152
|
+
twitter: config.declaredTwitter === true,
|
|
153
|
+
};
|
|
154
|
+
for (const descriptor of descriptors) {
|
|
155
|
+
if (descriptor.key.startsWith('meta:property=og:')) optedIn.og = true;
|
|
156
|
+
else if (descriptor.key.startsWith('meta:name=twitter:')) optedIn.twitter = true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const filled: SeoDescriptor[] = [...descriptors];
|
|
160
|
+
for (const entry of SOCIAL_FILL) {
|
|
161
|
+
if (!optedIn[entry.family] || present.has(entry.key)) continue;
|
|
162
|
+
const value = sources[entry.source];
|
|
163
|
+
if (value === undefined || value === '') continue;
|
|
164
|
+
filled.push({ tag: 'meta', key: entry.key, attrs: entry.attrs(value) });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (site === undefined && titleTemplate === undefined) return filled;
|
|
168
|
+
return filled.map((descriptor) => {
|
|
169
|
+
if (
|
|
170
|
+
titleTemplate !== undefined &&
|
|
171
|
+
descriptor.tag === 'title' &&
|
|
172
|
+
descriptor.text !== undefined &&
|
|
173
|
+
descriptor.text !== ''
|
|
174
|
+
) {
|
|
175
|
+
// Function replacement: the title is data, so its dollar patterns must not
|
|
176
|
+
// expand against the `%s` match.
|
|
177
|
+
return {
|
|
178
|
+
...descriptor,
|
|
179
|
+
text: titleTemplate.replace('%s', () => descriptor.text as string),
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
const attr = site === undefined ? null : urlAttribute(descriptor);
|
|
183
|
+
if (attr !== null) {
|
|
184
|
+
const value = descriptor.attrs[attr];
|
|
185
|
+
if (typeof value === 'string') {
|
|
186
|
+
const resolved = resolveUrl(value, site);
|
|
187
|
+
if (resolved !== value) {
|
|
188
|
+
return { ...descriptor, attrs: { ...descriptor.attrs, [attr]: resolved } };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return descriptor;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function stringAttr(
|
|
197
|
+
descriptors: readonly SeoDescriptor[],
|
|
198
|
+
key: string,
|
|
199
|
+
attr: string,
|
|
200
|
+
): string | undefined {
|
|
201
|
+
const value = descriptors.find((descriptor) => descriptor.key === key)?.attrs[attr];
|
|
202
|
+
return typeof value === 'string' ? value : undefined;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* How a `<link>`'s identity is derived. It has to differ by `rel`, because
|
|
207
|
+
* `href` is sometimes the value being set and sometimes the thing being
|
|
208
|
+
* identified, and getting that backwards breaks overrides in one direction or
|
|
209
|
+
* drops tags in the other.
|
|
210
|
+
*
|
|
211
|
+
* - **Singleton**: one per document, so `rel` alone is the identity.
|
|
212
|
+
* - **Slot-keyed**: the listed attributes name a slot and `href` is its value.
|
|
213
|
+
* A page replacing a layout's German alternate, or the same-sized icon, must
|
|
214
|
+
* win, so `href` is deliberately NOT part of the key.
|
|
215
|
+
* - **URL-keyed** (everything else): the target IS the identity, so two font
|
|
216
|
+
* preloads or two stylesheets coexist. Unknown rels default here on purpose,
|
|
217
|
+
* since dropping someone's tag is worse than emitting two.
|
|
218
|
+
*/
|
|
219
|
+
const SINGLETON_LINK_RELS = new Set(['canonical', 'manifest', 'author', 'license', 'prev', 'next']);
|
|
220
|
+
|
|
221
|
+
const SLOT_KEYED_LINK_RELS = new Map<string, readonly string[]>([
|
|
222
|
+
['alternate', ['hreflang', 'type', 'media', 'title']],
|
|
223
|
+
['icon', ['sizes', 'type', 'media']],
|
|
224
|
+
['shortcut icon', ['sizes', 'type']],
|
|
225
|
+
['apple-touch-icon', ['sizes', 'type']],
|
|
226
|
+
['apple-touch-icon-precomposed', ['sizes', 'type']],
|
|
227
|
+
['mask-icon', ['color']],
|
|
228
|
+
['search', ['type', 'title']],
|
|
229
|
+
]);
|
|
230
|
+
|
|
231
|
+
const URL_KEYED_LINK_ATTRS = ['href', 'as', 'media', 'type', 'hreflang', 'sizes'] as const;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Escape a value before it becomes part of an identity key. Keys join
|
|
235
|
+
* author-controlled values with `|` and `=`, so an unescaped value containing
|
|
236
|
+
* those could forge another tag's key and one of the two would be silently
|
|
237
|
+
* dropped from the document. Data stays data.
|
|
238
|
+
*/
|
|
239
|
+
function keyPart(value: string): string {
|
|
240
|
+
return value.replace(/[\\|=#]/g, (character) => '\\' + character);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function attrString(attrs: MetaAttributes, name: string): string | null {
|
|
244
|
+
const value = attrs[name];
|
|
245
|
+
if (value === null || value === undefined || value === false) return null;
|
|
246
|
+
return String(value);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Identity for a `<meta>`: whichever naming attribute it uses. Open Graph uses
|
|
251
|
+
* `property`, most others use `name`, and `http-equiv` is its own namespace, so
|
|
252
|
+
* `og:title` and a `name="og:title"` never collide by accident.
|
|
253
|
+
*/
|
|
254
|
+
export function metaKey(attrs: MetaAttributes): string {
|
|
255
|
+
if (attrs.charSet !== undefined || attrs.charset !== undefined) return 'meta:charset';
|
|
256
|
+
for (const naming of ['name', 'property', 'http-equiv', 'httpEquiv', 'itemprop']) {
|
|
257
|
+
const value = attrString(attrs, naming);
|
|
258
|
+
if (value !== null) {
|
|
259
|
+
const channel = naming === 'httpEquiv' ? 'http-equiv' : naming;
|
|
260
|
+
return 'meta:' + channel + '=' + keyPart(value);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// No naming attribute: keep every such tag by giving it a content-derived
|
|
264
|
+
// identity rather than silently collapsing unrelated tags together.
|
|
265
|
+
return 'meta:raw=' + JSON.stringify(attrs);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export function linkKey(attrs: MetaAttributes): string {
|
|
269
|
+
const rel = attrString(attrs, 'rel') ?? '';
|
|
270
|
+
if (SINGLETON_LINK_RELS.has(rel)) return 'link:' + rel;
|
|
271
|
+
const slotAttrs = SLOT_KEYED_LINK_RELS.get(rel);
|
|
272
|
+
let key = 'link:' + rel;
|
|
273
|
+
if (slotAttrs !== undefined) {
|
|
274
|
+
let named = false;
|
|
275
|
+
for (const name of slotAttrs) {
|
|
276
|
+
const value = attrString(attrs, name);
|
|
277
|
+
if (value !== null) {
|
|
278
|
+
key += '|' + name + '=' + keyPart(value);
|
|
279
|
+
named = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
// A slot-keyed rel carrying none of its discriminators names no slot, so
|
|
283
|
+
// fall through to the URL rather than collapsing unrelated tags together.
|
|
284
|
+
if (named) return key;
|
|
285
|
+
}
|
|
286
|
+
for (const name of URL_KEYED_LINK_ATTRS) {
|
|
287
|
+
const value = attrString(attrs, name);
|
|
288
|
+
if (value !== null) key += '|' + name + '=' + keyPart(value);
|
|
289
|
+
}
|
|
290
|
+
return key;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Merge registrations into the set to render, last-wins per identity.
|
|
295
|
+
*
|
|
296
|
+
* Insertion order is preserved for keys that survive: replacing a descriptor
|
|
297
|
+
* updates it in place rather than moving it to the end, so server and client
|
|
298
|
+
* produce the same order and hydration adoption stays positional. That also
|
|
299
|
+
* makes the merge idempotent across SSR suspense passes, where the same
|
|
300
|
+
* component re-registers the same key on every pass.
|
|
301
|
+
*/
|
|
302
|
+
export function mergeDescriptors(registrations: readonly SeoDescriptor[]): SeoDescriptor[] {
|
|
303
|
+
const byKey = new Map<string, number>();
|
|
304
|
+
const out: SeoDescriptor[] = [];
|
|
305
|
+
for (const descriptor of registrations) {
|
|
306
|
+
const at = byKey.get(descriptor.key);
|
|
307
|
+
if (at === undefined) {
|
|
308
|
+
byKey.set(descriptor.key, out.length);
|
|
309
|
+
out.push(descriptor);
|
|
310
|
+
} else {
|
|
311
|
+
out[at] = descriptor;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Absolute-ise a canonical/og:url style value against the configured site origin. */
|
|
318
|
+
export function resolveUrl(value: string, site: string | undefined): string {
|
|
319
|
+
if (site === undefined || /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//')) {
|
|
320
|
+
return value;
|
|
321
|
+
}
|
|
322
|
+
return site.replace(/\/+$/, '') + (value.startsWith('/') ? value : '/' + value);
|
|
323
|
+
}
|
package/src/expand.ts
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Expand the ergonomic `<Seo …>` object into the flat descriptor list the merge
|
|
3
|
+
* engine works on. Kept renderer-free and pure so precedence, URL resolution,
|
|
4
|
+
* and title templating are unit-testable without a render.
|
|
5
|
+
*/
|
|
6
|
+
import { linkKey, metaKey, type SeoDescriptor } from './descriptors.js';
|
|
7
|
+
|
|
8
|
+
export interface OpenGraphImage {
|
|
9
|
+
url: string;
|
|
10
|
+
alt?: string;
|
|
11
|
+
width?: number | string;
|
|
12
|
+
height?: number | string;
|
|
13
|
+
type?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface OpenGraphInput {
|
|
17
|
+
title?: string;
|
|
18
|
+
description?: string;
|
|
19
|
+
type?: string;
|
|
20
|
+
url?: string;
|
|
21
|
+
siteName?: string;
|
|
22
|
+
locale?: string;
|
|
23
|
+
images?: string | OpenGraphImage | Array<string | OpenGraphImage>;
|
|
24
|
+
publishedTime?: string;
|
|
25
|
+
modifiedTime?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface TwitterInput {
|
|
29
|
+
card?: 'summary' | 'summary_large_image' | 'app' | 'player';
|
|
30
|
+
site?: string;
|
|
31
|
+
creator?: string;
|
|
32
|
+
title?: string;
|
|
33
|
+
description?: string;
|
|
34
|
+
image?: string;
|
|
35
|
+
imageAlt?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RobotsInput {
|
|
39
|
+
index?: boolean;
|
|
40
|
+
follow?: boolean;
|
|
41
|
+
noarchive?: boolean;
|
|
42
|
+
nosnippet?: boolean;
|
|
43
|
+
maxSnippet?: number;
|
|
44
|
+
maxImagePreview?: 'none' | 'standard' | 'large';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface SeoInput {
|
|
48
|
+
title?: string;
|
|
49
|
+
/**
|
|
50
|
+
* `%s` is replaced by the page title. App-level: declaring it once applies it
|
|
51
|
+
* to whichever page renders, and it is applied after the merge, so social tags
|
|
52
|
+
* still mirror the untemplated title.
|
|
53
|
+
*/
|
|
54
|
+
titleTemplate?: string;
|
|
55
|
+
description?: string;
|
|
56
|
+
canonical?: string;
|
|
57
|
+
/** Origin used to absolute-ise canonical, og:url, and image URLs. */
|
|
58
|
+
site?: string;
|
|
59
|
+
robots?: string | RobotsInput;
|
|
60
|
+
openGraph?: OpenGraphInput;
|
|
61
|
+
twitter?: TwitterInput;
|
|
62
|
+
/** `hreflang` alternates: language tag to URL. */
|
|
63
|
+
languages?: Record<string, string>;
|
|
64
|
+
jsonLd?: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Escape a key component so a `@type` containing `#` cannot forge the key of a
|
|
69
|
+
* different graph and silently replace it.
|
|
70
|
+
*/
|
|
71
|
+
function jsonLdKeyPart(value: string): string {
|
|
72
|
+
return value.replace(/[\\#]/g, (character) => '\\' + character);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function meta(attrs: Record<string, string>): SeoDescriptor {
|
|
76
|
+
return { tag: 'meta', key: metaKey(attrs), attrs };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function link(attrs: Record<string, string>): SeoDescriptor {
|
|
80
|
+
return { tag: 'link', key: linkKey(attrs), attrs };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatRobots(robots: string | RobotsInput): string {
|
|
84
|
+
if (typeof robots === 'string') return robots;
|
|
85
|
+
const parts: string[] = [];
|
|
86
|
+
parts.push(robots.index === false ? 'noindex' : 'index');
|
|
87
|
+
parts.push(robots.follow === false ? 'nofollow' : 'follow');
|
|
88
|
+
if (robots.noarchive) parts.push('noarchive');
|
|
89
|
+
if (robots.nosnippet) parts.push('nosnippet');
|
|
90
|
+
if (robots.maxSnippet !== undefined) parts.push('max-snippet:' + robots.maxSnippet);
|
|
91
|
+
if (robots.maxImagePreview !== undefined) {
|
|
92
|
+
parts.push('max-image-preview:' + robots.maxImagePreview);
|
|
93
|
+
}
|
|
94
|
+
return parts.join(', ');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeImages(images: OpenGraphInput['images']): OpenGraphImage[] {
|
|
98
|
+
if (images === undefined) return [];
|
|
99
|
+
const list = Array.isArray(images) ? images : [images];
|
|
100
|
+
return list.map((image) => (typeof image === 'string' ? { url: image } : image));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function expandSeo(input: SeoInput): SeoDescriptor[] {
|
|
104
|
+
const out: SeoDescriptor[] = [];
|
|
105
|
+
|
|
106
|
+
// The RAW title. `titleTemplate` is app-level config applied after the merge,
|
|
107
|
+
// so the social mirror always sees the untemplated value regardless of which
|
|
108
|
+
// `<Seo>` declared the template.
|
|
109
|
+
if (input.title !== undefined) {
|
|
110
|
+
out.push({ tag: 'title', key: 'title', attrs: {}, text: input.title });
|
|
111
|
+
}
|
|
112
|
+
if (input.description !== undefined) {
|
|
113
|
+
out.push(meta({ name: 'description', content: input.description }));
|
|
114
|
+
}
|
|
115
|
+
if (input.robots !== undefined) {
|
|
116
|
+
out.push(meta({ name: 'robots', content: formatRobots(input.robots) }));
|
|
117
|
+
}
|
|
118
|
+
if (input.canonical !== undefined) {
|
|
119
|
+
out.push(link({ rel: 'canonical', href: input.canonical }));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const og = input.openGraph;
|
|
123
|
+
if (og !== undefined) {
|
|
124
|
+
// Open Graph falls back to the page title/description so the common case
|
|
125
|
+
// needs no duplication, while an explicit og value still overrides.
|
|
126
|
+
const ogTitle = og.title ?? input.title;
|
|
127
|
+
const ogDescription = og.description ?? input.description;
|
|
128
|
+
const ogUrl = og.url ?? input.canonical;
|
|
129
|
+
if (og.type !== undefined) out.push(meta({ property: 'og:type', content: og.type }));
|
|
130
|
+
if (og.siteName !== undefined) {
|
|
131
|
+
out.push(meta({ property: 'og:site_name', content: og.siteName }));
|
|
132
|
+
}
|
|
133
|
+
if (ogTitle !== undefined) out.push(meta({ property: 'og:title', content: ogTitle }));
|
|
134
|
+
if (ogDescription !== undefined) {
|
|
135
|
+
out.push(meta({ property: 'og:description', content: ogDescription }));
|
|
136
|
+
}
|
|
137
|
+
if (ogUrl !== undefined) {
|
|
138
|
+
out.push(meta({ property: 'og:url', content: ogUrl }));
|
|
139
|
+
}
|
|
140
|
+
if (og.locale !== undefined) out.push(meta({ property: 'og:locale', content: og.locale }));
|
|
141
|
+
if (og.publishedTime !== undefined) {
|
|
142
|
+
out.push(meta({ property: 'article:published_time', content: og.publishedTime }));
|
|
143
|
+
}
|
|
144
|
+
if (og.modifiedTime !== undefined) {
|
|
145
|
+
out.push(meta({ property: 'article:modified_time', content: og.modifiedTime }));
|
|
146
|
+
}
|
|
147
|
+
// URLs stay as authored here. `applyConfig` absolute-ises them after the
|
|
148
|
+
// merge, so one place decides which origin applies.
|
|
149
|
+
const images = normalizeImages(og.images);
|
|
150
|
+
for (let i = 0; i < images.length; i++) {
|
|
151
|
+
const image = images[i];
|
|
152
|
+
const url = image.url;
|
|
153
|
+
// Repeated og:image tags are legitimate, so each gets its own identity.
|
|
154
|
+
out.push({
|
|
155
|
+
tag: 'meta',
|
|
156
|
+
key: 'meta:property=og:image[' + i + ']',
|
|
157
|
+
attrs: { property: 'og:image', content: url },
|
|
158
|
+
});
|
|
159
|
+
if (image.alt !== undefined) {
|
|
160
|
+
out.push({
|
|
161
|
+
tag: 'meta',
|
|
162
|
+
key: 'meta:property=og:image:alt[' + i + ']',
|
|
163
|
+
attrs: { property: 'og:image:alt', content: image.alt },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
if (image.width !== undefined) {
|
|
167
|
+
out.push({
|
|
168
|
+
tag: 'meta',
|
|
169
|
+
key: 'meta:property=og:image:width[' + i + ']',
|
|
170
|
+
attrs: { property: 'og:image:width', content: String(image.width) },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
if (image.height !== undefined) {
|
|
174
|
+
out.push({
|
|
175
|
+
tag: 'meta',
|
|
176
|
+
key: 'meta:property=og:image:height[' + i + ']',
|
|
177
|
+
attrs: { property: 'og:image:height', content: String(image.height) },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (image.type !== undefined) {
|
|
181
|
+
out.push({
|
|
182
|
+
tag: 'meta',
|
|
183
|
+
key: 'meta:property=og:image:type[' + i + ']',
|
|
184
|
+
attrs: { property: 'og:image:type', content: image.type },
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const twitter = input.twitter;
|
|
191
|
+
if (twitter !== undefined) {
|
|
192
|
+
if (twitter.card !== undefined) out.push(meta({ name: 'twitter:card', content: twitter.card }));
|
|
193
|
+
if (twitter.site !== undefined) out.push(meta({ name: 'twitter:site', content: twitter.site }));
|
|
194
|
+
if (twitter.creator !== undefined) {
|
|
195
|
+
out.push(meta({ name: 'twitter:creator', content: twitter.creator }));
|
|
196
|
+
}
|
|
197
|
+
const twitterTitle = twitter.title ?? input.title;
|
|
198
|
+
const twitterDescription = twitter.description ?? input.description;
|
|
199
|
+
if (twitterTitle !== undefined) {
|
|
200
|
+
out.push(meta({ name: 'twitter:title', content: twitterTitle }));
|
|
201
|
+
}
|
|
202
|
+
if (twitterDescription !== undefined) {
|
|
203
|
+
out.push(meta({ name: 'twitter:description', content: twitterDescription }));
|
|
204
|
+
}
|
|
205
|
+
if (twitter.image !== undefined) {
|
|
206
|
+
out.push(meta({ name: 'twitter:image', content: twitter.image }));
|
|
207
|
+
}
|
|
208
|
+
if (twitter.imageAlt !== undefined) {
|
|
209
|
+
out.push(meta({ name: 'twitter:image:alt', content: twitter.imageAlt }));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (input.languages !== undefined) {
|
|
214
|
+
for (const [hreflang, href] of Object.entries(input.languages)) {
|
|
215
|
+
out.push(link({ rel: 'alternate', hreflang, href }));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (input.jsonLd !== undefined) {
|
|
220
|
+
const descriptor = jsonLdDescriptor(input.jsonLd);
|
|
221
|
+
if (descriptor !== null) out.push(descriptor);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* JSON-LD identity is its `@type` (plus `@id` when present), so a page-level
|
|
229
|
+
* Article replaces a layout-level Article but coexists with a BreadcrumbList.
|
|
230
|
+
*/
|
|
231
|
+
export function jsonLdDescriptor(data: unknown, explicitKey?: string): SeoDescriptor | null {
|
|
232
|
+
// Serialization is the one place author data can take the whole render down:
|
|
233
|
+
// a cyclic graph or a BigInt makes JSON.stringify throw, and that would 500 a
|
|
234
|
+
// page over one structured-data object. Degrade instead, loudly. A graph that
|
|
235
|
+
// cannot serialize cannot be valid structured data either, so emitting nothing
|
|
236
|
+
// is better than emitting something broken.
|
|
237
|
+
let text: string;
|
|
238
|
+
try {
|
|
239
|
+
text = JSON.stringify(data);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.error(
|
|
242
|
+
'[@octanejs/seo] Could not serialize JSON-LD, so no structured data was ' +
|
|
243
|
+
'emitted for it. Values such as cycles and BigInt cannot be represented: ' +
|
|
244
|
+
(error instanceof Error ? error.message : String(error)),
|
|
245
|
+
);
|
|
246
|
+
return null;
|
|
247
|
+
}
|
|
248
|
+
if (text === undefined) return null;
|
|
249
|
+
let key = explicitKey;
|
|
250
|
+
if (key === undefined) {
|
|
251
|
+
const record = (data ?? {}) as Record<string, unknown>;
|
|
252
|
+
const type = typeof record['@type'] === 'string' ? (record['@type'] as string) : 'graph';
|
|
253
|
+
const id = typeof record['@id'] === 'string' ? (record['@id'] as string) : '';
|
|
254
|
+
key = jsonLdKeyPart(type) + '#' + jsonLdKeyPart(id);
|
|
255
|
+
}
|
|
256
|
+
return {
|
|
257
|
+
tag: 'script',
|
|
258
|
+
key: 'jsonLd:' + key,
|
|
259
|
+
attrs: { type: 'application/ld+json' },
|
|
260
|
+
text,
|
|
261
|
+
};
|
|
262
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@octanejs/seo`, declarative document metadata for Octane.
|
|
3
|
+
*
|
|
4
|
+
* `<Head>` is the only component to learn. Wrap the app in one, then use it
|
|
5
|
+
* again wherever metadata belongs, filled with `<Title>`, `<Meta>`, `<Link>`,
|
|
6
|
+
* `<Script>`, `<JsonLd>`, or the all-in-one `<Seo>`. Blocks merge wherever they
|
|
7
|
+
* sit, siblings included, last-wins per identity, so a page overrides a layout
|
|
8
|
+
* simply by rendering later. Metadata is server-rendered into `<head>` and
|
|
9
|
+
* adopted on hydration rather than duplicated.
|
|
10
|
+
*
|
|
11
|
+
* Why the merge matters: the platform takes the FIRST duplicate in tree order
|
|
12
|
+
* (`document.title` is the first `<title>` in the document, and a crawler reads
|
|
13
|
+
* the first `meta[name=description]`), while authoring order puts layout
|
|
14
|
+
* defaults before page specifics. Appending both would let the generic value
|
|
15
|
+
* win every time, so identity-keyed last-wins is applied before anything is
|
|
16
|
+
* emitted.
|
|
17
|
+
*/
|
|
18
|
+
export { Head, Title, Meta, Link, Script, JsonLd, Seo } from './components.tsrx';
|
|
19
|
+
export { applyConfig, mergeDescriptors, metaKey, linkKey, resolveUrl } from './descriptors.js';
|
|
20
|
+
export type { SeoConfig, SeoDescriptor, MetaAttributes } from './descriptors.js';
|
|
21
|
+
export { expandSeo, formatRobots, jsonLdDescriptor } from './expand.js';
|
|
22
|
+
export type {
|
|
23
|
+
SeoInput,
|
|
24
|
+
OpenGraphInput,
|
|
25
|
+
OpenGraphImage,
|
|
26
|
+
TwitterInput,
|
|
27
|
+
RobotsInput,
|
|
28
|
+
} from './expand.js';
|
|
29
|
+
export { createSeoRegistry } from './registry.js';
|
|
30
|
+
export type { SeoRegistry } from './registry.js';
|
|
31
|
+
export { SeoContext } from './context.js';
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The per-tree registry every `<Title>`/`<Meta>`/`<Link>`/`<JsonLd>` writes into
|
|
3
|
+
* and `<SeoOutlet>` reads back.
|
|
4
|
+
*
|
|
5
|
+
* The registry is created by `<SeoProvider>` and passed through context, never
|
|
6
|
+
* held in module scope: concurrent SSR requests each get their own, so one
|
|
7
|
+
* request can never observe another's metadata.
|
|
8
|
+
*
|
|
9
|
+
* Registrations are grouped by SOURCE (one per component instance) rather than
|
|
10
|
+
* flattened on write, so a source that re-renders with a different key replaces
|
|
11
|
+
* its previous entry instead of leaving the stale one behind, and a source that
|
|
12
|
+
* unmounts takes its metadata with it. Source order is insertion order, which is
|
|
13
|
+
* render order, which is why the outlet emits the same sequence on the server
|
|
14
|
+
* and the client and hydration adoption stays positional.
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
applyConfig,
|
|
18
|
+
mergeDescriptors,
|
|
19
|
+
type SeoConfig,
|
|
20
|
+
type SeoDescriptor,
|
|
21
|
+
} from './descriptors.js';
|
|
22
|
+
|
|
23
|
+
export interface SeoRegistry {
|
|
24
|
+
/** Record (or replace) one source's descriptors. Called during render. */
|
|
25
|
+
register(sourceId: number, descriptors: readonly SeoDescriptor[]): void;
|
|
26
|
+
/** Record app-level settings from one source; merged last-wins like metadata. */
|
|
27
|
+
configure(sourceId: number, config: SeoConfig): void;
|
|
28
|
+
remove(sourceId: number): void;
|
|
29
|
+
/** Allocate a stable id for a component instance. */
|
|
30
|
+
nextSourceId(): number;
|
|
31
|
+
/** Merged, last-wins descriptor list. Referentially stable between changes. */
|
|
32
|
+
getSnapshot(): readonly SeoDescriptor[];
|
|
33
|
+
subscribe(listener: () => void): () => void;
|
|
34
|
+
/** Notify subscribers if anything changed since the last flush. */
|
|
35
|
+
flush(): void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function sameDescriptors(a: readonly SeoDescriptor[], b: readonly SeoDescriptor[]): boolean {
|
|
39
|
+
if (a.length !== b.length) return false;
|
|
40
|
+
for (let i = 0; i < a.length; i++) {
|
|
41
|
+
const x = a[i];
|
|
42
|
+
const y = b[i];
|
|
43
|
+
if (x.key !== y.key || x.tag !== y.tag || x.text !== y.text) return false;
|
|
44
|
+
const xa = x.attrs;
|
|
45
|
+
const ya = y.attrs;
|
|
46
|
+
const xk = Object.keys(xa);
|
|
47
|
+
if (xk.length !== Object.keys(ya).length) return false;
|
|
48
|
+
for (const k of xk) if (xa[k] !== ya[k]) return false;
|
|
49
|
+
}
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function sameConfig(a: SeoConfig, b: SeoConfig): boolean {
|
|
54
|
+
const keys = new Set([...Object.keys(a), ...Object.keys(b)]) as Set<keyof SeoConfig>;
|
|
55
|
+
for (const key of keys) if (a[key] !== b[key]) return false;
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function createSeoRegistry(): SeoRegistry {
|
|
60
|
+
const sources = new Map<number, readonly SeoDescriptor[]>();
|
|
61
|
+
const configs = new Map<number, SeoConfig>();
|
|
62
|
+
const listeners = new Set<() => void>();
|
|
63
|
+
let nextId = 1;
|
|
64
|
+
let snapshot: readonly SeoDescriptor[] | null = null;
|
|
65
|
+
let dirty = false;
|
|
66
|
+
|
|
67
|
+
function invalidate(): void {
|
|
68
|
+
snapshot = null;
|
|
69
|
+
dirty = true;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
nextSourceId() {
|
|
74
|
+
return nextId++;
|
|
75
|
+
},
|
|
76
|
+
register(sourceId, descriptors) {
|
|
77
|
+
const previous = sources.get(sourceId);
|
|
78
|
+
// Re-registering identical descriptors is the common case on an SSR
|
|
79
|
+
// suspense re-pass and on any parent re-render; treating it as a no-op
|
|
80
|
+
// keeps the snapshot reference stable so the outlet does not re-render.
|
|
81
|
+
if (previous !== undefined && sameDescriptors(previous, descriptors)) return;
|
|
82
|
+
sources.set(sourceId, descriptors);
|
|
83
|
+
invalidate();
|
|
84
|
+
},
|
|
85
|
+
configure(sourceId, config) {
|
|
86
|
+
const previous = configs.get(sourceId);
|
|
87
|
+
// Compared field-by-field over the union of keys, so adding a SeoConfig
|
|
88
|
+
// field cannot silently escape this check and stop invalidating.
|
|
89
|
+
if (previous !== undefined && sameConfig(previous, config)) return;
|
|
90
|
+
configs.set(sourceId, config);
|
|
91
|
+
invalidate();
|
|
92
|
+
},
|
|
93
|
+
remove(sourceId) {
|
|
94
|
+
const had = sources.delete(sourceId);
|
|
95
|
+
if (configs.delete(sourceId) || had) invalidate();
|
|
96
|
+
},
|
|
97
|
+
getSnapshot() {
|
|
98
|
+
if (snapshot === null) {
|
|
99
|
+
const flat: SeoDescriptor[] = [];
|
|
100
|
+
for (const descriptors of sources.values()) flat.push(...descriptors);
|
|
101
|
+
const effective: SeoConfig = {};
|
|
102
|
+
for (const config of configs.values()) {
|
|
103
|
+
if (config.site !== undefined) effective.site = config.site;
|
|
104
|
+
if (config.titleTemplate !== undefined) effective.titleTemplate = config.titleTemplate;
|
|
105
|
+
// Declaring a social family anywhere opts the whole tree in.
|
|
106
|
+
if (config.declaredOpenGraph === true) effective.declaredOpenGraph = true;
|
|
107
|
+
if (config.declaredTwitter === true) effective.declaredTwitter = true;
|
|
108
|
+
}
|
|
109
|
+
snapshot = applyConfig(mergeDescriptors(flat), effective);
|
|
110
|
+
}
|
|
111
|
+
return snapshot;
|
|
112
|
+
},
|
|
113
|
+
subscribe(listener) {
|
|
114
|
+
listeners.add(listener);
|
|
115
|
+
return () => {
|
|
116
|
+
listeners.delete(listener);
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
flush() {
|
|
120
|
+
if (!dirty) return;
|
|
121
|
+
dirty = false;
|
|
122
|
+
for (const listener of listeners) listener();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Register one component instance's descriptors with the enclosing registry.
|
|
3
|
+
*
|
|
4
|
+
* Registration happens DURING RENDER, not in an effect: effects never run on the
|
|
5
|
+
* server, so an effect-based registration would leave the server-rendered
|
|
6
|
+
* `<head>` empty and hand crawlers nothing. That is precisely the failure this
|
|
7
|
+
* package exists to remove.
|
|
8
|
+
*
|
|
9
|
+
* The effect below exists only for the client half of the lifecycle: it notifies
|
|
10
|
+
* the outlet after a commit that changed something, and removes the source on
|
|
11
|
+
* unmount so a navigated-away page stops contributing. Document metadata does
|
|
12
|
+
* not affect layout, so it does not need to block paint.
|
|
13
|
+
*/
|
|
14
|
+
import { useContext, useEffect, useRef } from 'octane';
|
|
15
|
+
import { SeoContext } from './context.js';
|
|
16
|
+
import type { SeoConfig, SeoDescriptor } from './descriptors.js';
|
|
17
|
+
|
|
18
|
+
export function useRegisterSeo(descriptors: readonly SeoDescriptor[], config?: SeoConfig): void {
|
|
19
|
+
const registry = useContext(SeoContext);
|
|
20
|
+
if (registry === null) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'[@octanejs/seo] <Title>/<Meta>/<Link>/<Script>/<JsonLd>/<Seo> must render inside a <Head>.',
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
const idRef = useRef<number>(0);
|
|
26
|
+
if (idRef.current === 0) idRef.current = registry.nextSourceId();
|
|
27
|
+
registry.register(idRef.current, descriptors);
|
|
28
|
+
if (config !== undefined) registry.configure(idRef.current, config);
|
|
29
|
+
|
|
30
|
+
// Two effects, and they must stay separate.
|
|
31
|
+
//
|
|
32
|
+
// Publish re-registers the committed descriptors and notifies the outlet. The
|
|
33
|
+
// dependency array is left to the compiler on purpose: `descriptors` is read
|
|
34
|
+
// here, so inference keys the effect on it and re-runs exactly when the
|
|
35
|
+
// metadata this component contributes may have changed. Both calls are
|
|
36
|
+
// idempotent, re-registering an equal set is a no-op and `flush()` does
|
|
37
|
+
// nothing unless something actually changed.
|
|
38
|
+
useEffect(() => {
|
|
39
|
+
registry.register(idRef.current, descriptors);
|
|
40
|
+
if (config !== undefined) registry.configure(idRef.current, config);
|
|
41
|
+
registry.flush();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Teardown is mount/unmount ONLY. It cannot ride the effect above: that one
|
|
45
|
+
// re-runs, and a cleanup running before each re-run would make a component
|
|
46
|
+
// delete its own registration merely by re-rendering, letting an outer
|
|
47
|
+
// default win. This closure reads nothing that changes, so inference gives it
|
|
48
|
+
// mount/unmount semantics on its own.
|
|
49
|
+
useEffect(() => {
|
|
50
|
+
return () => {
|
|
51
|
+
registry.remove(idRef.current);
|
|
52
|
+
registry.flush();
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Development check for the one arrangement that produces wrong metadata: two
|
|
3
|
+
* OUTERMOST `<Head>` elements, neither containing the other.
|
|
4
|
+
*
|
|
5
|
+
* Each owns a registry and emits its own merged set, so the document ends up
|
|
6
|
+
* with two `<title>` elements and the platform keeps the first. Whichever
|
|
7
|
+
* component happened to render first wins, which is silent and arbitrary. The
|
|
8
|
+
* fix is always the same, wrap the app in one `<Head>` so every other block
|
|
9
|
+
* resolves to it.
|
|
10
|
+
*
|
|
11
|
+
* Reported with `console.error`, not `warn`: this is a correctness bug in the
|
|
12
|
+
* page's metadata, not a style note.
|
|
13
|
+
*
|
|
14
|
+
* Client-only by construction. It counts in an effect, and effects never run on
|
|
15
|
+
* the server, so concurrent SSR requests cannot inflate a shared counter.
|
|
16
|
+
*/
|
|
17
|
+
import { useEffect } from 'octane';
|
|
18
|
+
|
|
19
|
+
let liveOwners = 0;
|
|
20
|
+
let reported = false;
|
|
21
|
+
|
|
22
|
+
export function useStrayOwnerDiagnostic(owns: boolean): void {
|
|
23
|
+
if (process.env.NODE_ENV === 'production') return;
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (!owns) return;
|
|
26
|
+
liveOwners++;
|
|
27
|
+
if (liveOwners > 1 && !reported) {
|
|
28
|
+
reported = true;
|
|
29
|
+
console.error(
|
|
30
|
+
'[@octanejs/seo] Two <Head> elements are mounted with neither containing the ' +
|
|
31
|
+
'other, so each emits its own merged set: the document will carry duplicate ' +
|
|
32
|
+
'tags and the FIRST in document order will win, which makes the other ' +
|
|
33
|
+
"component's metadata silently ineffective. Wrap the app in a single " +
|
|
34
|
+
'<Head> so every other <Head> block merges into it.',
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return () => {
|
|
38
|
+
liveOwners--;
|
|
39
|
+
if (liveOwners <= 1) reported = false;
|
|
40
|
+
};
|
|
41
|
+
});
|
|
42
|
+
}
|