@menapo/seo 0.0.0-stage → 0.1.0
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 +27 -2
- package/dist/html-Cs8NzYrD.js +76 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +66 -0
- package/dist/locales/none.d.ts +16 -0
- package/dist/locales/none.js +4 -0
- package/dist/locales/vi18n.d.ts +5 -0
- package/dist/locales/vi18n.js +40 -0
- package/dist/scope-B-mGqw0E.js +4 -0
- package/dist/server.d.ts +21 -0
- package/dist/server.js +70 -0
- package/dist/tags-N6pWK3eY.js +135 -0
- package/dist/test.d.ts +13 -0
- package/dist/test.js +20 -0
- package/dist/types-C_iXY4pr.d.ts +54 -0
- package/dist/vite.d.ts +28 -0
- package/dist/vite.js +426 -0
- package/package.json +66 -3
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 menapo
|
|
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
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @menapo/seo
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Head tags, hreflang and sitemaps for kit apps: one `head()` call per page,
|
|
4
|
+
in React, Preact, Vue, Svelte, Solid, Astro and plain code. The output is
|
|
5
|
+
DOM and response edits, never components.
|
|
6
|
+
|
|
7
|
+
**Status: built, private until 1.0.** `head()` in the browser and on the
|
|
8
|
+
server, `withHead()` from `/server`, `/test`'s `headOf()`, and the plugin
|
|
9
|
+
(defaults, vi18n hreflang, `index.html` defaults, `sitemap.xml` and
|
|
10
|
+
`robots.txt` after the build) are implemented and tested;
|
|
11
|
+
`examples/seo-vanilla` prerenders through `withHead()` and navigates in
|
|
12
|
+
Chromium. The release waits on the owner's review.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// vite.config.ts
|
|
16
|
+
plugins: [vi18n(), seo({ site: "https://acme.com", title: "%s · Acme", image: "/og.png" })];
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { head } from "@menapo/seo";
|
|
21
|
+
|
|
22
|
+
head({ title: t("Pricing"), description: t("Plans for every team") });
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
- [Guide](./docs/guide/README.md): the plugin, `head()`, servers, the sitemap.
|
|
26
|
+
- [Plan](./docs/PLAN.md): the design, its decisions and measurements.
|
|
27
|
+
- Benchmarks: `vp run --filter @menapo/bench bench:seo`
|
|
28
|
+
([bench/README.md](../../bench/README.md#menaposeo)).
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { t as keyOf } from "./tags-N6pWK3eY.js";
|
|
2
|
+
//#region src/html.ts
|
|
3
|
+
/**
|
|
4
|
+
* Head HTML as text, for the server's response rewrite, the plugin's
|
|
5
|
+
* `index.html` step and the sitemap's crawl. Not a parser: it reads the
|
|
6
|
+
* `<title>`, `<meta>`, `<link>` and `<script>` elements of one `<head>`,
|
|
7
|
+
* which is all seo writes or needs to read.
|
|
8
|
+
*/
|
|
9
|
+
const ESCAPES = {
|
|
10
|
+
"&": "&",
|
|
11
|
+
"<": "<",
|
|
12
|
+
">": ">",
|
|
13
|
+
"\"": """
|
|
14
|
+
};
|
|
15
|
+
const ESCAPE = /[&<>"]/;
|
|
16
|
+
const escape = (s) => ESCAPE.test(s) ? s.replace(/[&<>"]/g, (c) => ESCAPES[c]) : s;
|
|
17
|
+
const ENTITIES = {
|
|
18
|
+
amp: "&",
|
|
19
|
+
lt: "<",
|
|
20
|
+
gt: ">",
|
|
21
|
+
quot: "\"",
|
|
22
|
+
"#39": "'",
|
|
23
|
+
apos: "'"
|
|
24
|
+
};
|
|
25
|
+
const unescape = (s) => s.replace(/&(amp|lt|gt|quot|#39|apos);/g, (_, e) => ENTITIES[e]);
|
|
26
|
+
/** The tags as HTML. JSON-LD is already escaped for a script body. */
|
|
27
|
+
function render(tags) {
|
|
28
|
+
let out = "";
|
|
29
|
+
for (const t of tags) {
|
|
30
|
+
let attrs = "";
|
|
31
|
+
for (const name in t.attrs) attrs += " " + name + "=\"" + escape(t.attrs[name]) + "\"";
|
|
32
|
+
const text = t.tag === "title" ? escape(t.text ?? "") : t.text ?? "";
|
|
33
|
+
out += t.tag === "meta" || t.tag === "link" ? `<${t.tag}${attrs}>` : `<${t.tag}${attrs}>${text}</${t.tag}>`;
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
const ELEMENT = /<(title|script)\b([^>]*)>[\s\S]*?<\/\1\s*>|<(meta|link)\b([^>]*?)\/?>/gi;
|
|
38
|
+
const ATTR = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
39
|
+
/** The elements seo reads in `head` (the text of one `<head>`). */
|
|
40
|
+
function scan(head) {
|
|
41
|
+
const out = [];
|
|
42
|
+
for (const m of head.matchAll(ELEMENT)) {
|
|
43
|
+
const attrs = {};
|
|
44
|
+
for (const a of (m[2] ?? m[4] ?? "").matchAll(ATTR)) attrs[a[1].toLowerCase()] = unescape(a[2] ?? a[3] ?? a[4] ?? "");
|
|
45
|
+
out.push({
|
|
46
|
+
tag: (m[1] ?? m[3]).toLowerCase(),
|
|
47
|
+
attrs,
|
|
48
|
+
start: m.index,
|
|
49
|
+
end: m.index + m[0].length
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
const HEAD_END = /<\/head\s*>/i;
|
|
55
|
+
/** The index of `</head>` in `html`, or -1. */
|
|
56
|
+
const headEnd = (html) => html.search(HEAD_END);
|
|
57
|
+
/**
|
|
58
|
+
* `html` (a document up to and including `</head>`) with every element whose
|
|
59
|
+
* key one of `tags` has removed, and `tags` inserted before `</head>`.
|
|
60
|
+
*/
|
|
61
|
+
function rewrite(html, tags) {
|
|
62
|
+
const end = headEnd(html);
|
|
63
|
+
if (end < 0) return html;
|
|
64
|
+
const start = Math.max(0, html.search(/<head\b[^>]*>/i));
|
|
65
|
+
const keys = new Set(tags.map((t) => keyOf(t.tag, t.attrs)));
|
|
66
|
+
let out = "";
|
|
67
|
+
let at = start;
|
|
68
|
+
for (const f of scan(html.slice(start, end))) {
|
|
69
|
+
if (!keys.has(keyOf(f.tag, f.attrs))) continue;
|
|
70
|
+
out += html.slice(at, start + f.start);
|
|
71
|
+
at = start + f.end;
|
|
72
|
+
}
|
|
73
|
+
return html.slice(0, start) + out + html.slice(at, end) + render(tags) + html.slice(end);
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { render as n, rewrite as r, headEnd as t };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { a as Tag, i as SeoDefaults, n as Image, r as JsonLd, t as HeadInput } from "./types-C_iXY4pr.js";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Sets the page's head: the plugin's defaults with `input` on top. Each call
|
|
5
|
+
* replaces the previous one's tags, so the next page needs no cleanup.
|
|
6
|
+
*/
|
|
7
|
+
export declare function head(input?: HeadInput): void;
|
|
8
|
+
//#endregion
|
|
9
|
+
export type { HeadInput, Image, JsonLd, SeoDefaults, Tag };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { n as tags, t as keyOf } from "./tags-N6pWK3eY.js";
|
|
2
|
+
import { t as shared } from "./scope-B-mGqw0E.js";
|
|
3
|
+
//#region src/dom.ts
|
|
4
|
+
/**
|
|
5
|
+
* The browser's half of `head()` (PLAN D3): the owned tags of `document.head`
|
|
6
|
+
* become exactly `tags`. Elements whose key and values already match are
|
|
7
|
+
* left alone, so the head a server wrote is adopted on hydration with no
|
|
8
|
+
* DOM write; owned tags the call did not write are removed.
|
|
9
|
+
*/
|
|
10
|
+
const OWNED = "title,meta[name],meta[property],link[rel=canonical],link[rel=alternate][hreflang],script[type=\"application/ld+json\"]";
|
|
11
|
+
function attrsOf(el) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const a of el.attributes) out[a.name] = a.value;
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
function apply(tags) {
|
|
17
|
+
const head = document.head;
|
|
18
|
+
const old = /* @__PURE__ */ new Map();
|
|
19
|
+
for (const el of head.querySelectorAll(OWNED)) {
|
|
20
|
+
const key = keyOf(el.localName, attrsOf(el));
|
|
21
|
+
if (key === void 0) continue;
|
|
22
|
+
if (old.has(key)) el.remove();
|
|
23
|
+
else old.set(key, el);
|
|
24
|
+
}
|
|
25
|
+
let prev;
|
|
26
|
+
for (const t of tags) {
|
|
27
|
+
const key = keyOf(t.tag, t.attrs);
|
|
28
|
+
let el = old.get(key);
|
|
29
|
+
old.delete(key);
|
|
30
|
+
if (!el) {
|
|
31
|
+
el = document.createElement(t.tag);
|
|
32
|
+
if (prev) prev.after(el);
|
|
33
|
+
else head.append(el);
|
|
34
|
+
}
|
|
35
|
+
prev = el;
|
|
36
|
+
for (const [name, value] of Object.entries(t.attrs)) if (el.getAttribute(name) !== value) el.setAttribute(name, value);
|
|
37
|
+
if (t.text !== void 0 && el.textContent !== t.text) el.textContent = t.text;
|
|
38
|
+
}
|
|
39
|
+
for (const el of old.values()) el.remove();
|
|
40
|
+
}
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region src/index.ts
|
|
43
|
+
/**
|
|
44
|
+
* `@menapo/seo`: one `head()` call per page (PLAN §1). In the browser it
|
|
45
|
+
* updates `document.head`; on the server it records into the request that
|
|
46
|
+
* `withHead()` from `/server` opened, which writes the tags into the
|
|
47
|
+
* response. Imports nothing from the build side: the plugin's defaults
|
|
48
|
+
* arrive as the `__SEO__` define.
|
|
49
|
+
*/
|
|
50
|
+
const DEFAULTS = typeof __SEO__ === "undefined" ? {} : __SEO__;
|
|
51
|
+
/**
|
|
52
|
+
* Sets the page's head: the plugin's defaults with `input` on top. Each call
|
|
53
|
+
* replaces the previous one's tags, so the next page needs no cleanup.
|
|
54
|
+
*/
|
|
55
|
+
function head(input = {}) {
|
|
56
|
+
if (typeof document === "undefined") {
|
|
57
|
+
const scope = shared().scope?.();
|
|
58
|
+
if (!scope) throw new Error("seo: head() ran on the server outside a request. Wrap the request handler in withHead(request, render) from \"@menapo/seo/server\".");
|
|
59
|
+
tags(input, DEFAULTS, scope.url);
|
|
60
|
+
scope.input = input;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
apply(tags(input, DEFAULTS, new URL(location.href)));
|
|
64
|
+
}
|
|
65
|
+
//#endregion
|
|
66
|
+
export { head };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
//#region src/locales/none.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* The locale module without vi18n: no hreflang. The plugin loads
|
|
4
|
+
* `vi18n.ts` in its place when `vi18n()` is in the Vite config (PLAN D7),
|
|
5
|
+
* so apps without locales ship none of it.
|
|
6
|
+
*/
|
|
7
|
+
export interface Alternates {
|
|
8
|
+
/** The page's canonical in its own locale. */
|
|
9
|
+
readonly canonical: string;
|
|
10
|
+
/** `[hreflang, href]`, `x-default` last; empty when every locale shares one URL. */
|
|
11
|
+
readonly links: readonly (readonly [string, string])[];
|
|
12
|
+
/** `og:locale`, then `og:locale:alternate` values, in `ll_TT` form. */
|
|
13
|
+
readonly og: readonly string[];
|
|
14
|
+
}
|
|
15
|
+
export declare function alternates(_url: URL): Alternates | undefined;
|
|
16
|
+
//#endregion
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { getLocale, locales, localizeUrl } from "@menapo/vi18n";
|
|
2
|
+
//#region src/locales/vi18n.ts
|
|
3
|
+
/**
|
|
4
|
+
* The locale module with vi18n (PLAN D7): every advertised locale's URL of
|
|
5
|
+
* the page from vi18n's own `localizeUrl`, so prefixes, localized paths and
|
|
6
|
+
* domains come out as vi18n routes them, on the server and in the browser
|
|
7
|
+
* alike. The source and pseudo-locales are read from vi18n's page state,
|
|
8
|
+
* the protocol surface its runtime copies share, until vi18n exports them
|
|
9
|
+
* (PLAN §7); tests/vi18n.test.ts pins the shape against the real runtime.
|
|
10
|
+
*/
|
|
11
|
+
/** `pt-BR` → `pt_BR`; a missing region from the likely subtags (`de` → `de_DE`), as vi18n's `ogLocale`. */
|
|
12
|
+
function og(locale) {
|
|
13
|
+
try {
|
|
14
|
+
const l = new Intl.Locale(locale);
|
|
15
|
+
const region = l.region ?? l.maximize().region;
|
|
16
|
+
return region ? l.language + "_" + region : l.language;
|
|
17
|
+
} catch {
|
|
18
|
+
return locale;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function alternates(url) {
|
|
22
|
+
const config = globalThis[Symbol.for("menapo.vi18n")]?.registration?.config;
|
|
23
|
+
const pseudo = config?.pseudoLocales ?? [];
|
|
24
|
+
const listed = locales.filter((l) => !pseudo.includes(l));
|
|
25
|
+
const at = (l) => {
|
|
26
|
+
const u = localizeUrl(url, l);
|
|
27
|
+
return u.origin + u.pathname;
|
|
28
|
+
};
|
|
29
|
+
const current = getLocale();
|
|
30
|
+
const hrefs = listed.map(at);
|
|
31
|
+
const links = new Set(hrefs).size > 1 ? listed.map((l, i) => [l, hrefs[i]]) : [];
|
|
32
|
+
if (links.length) links.push(["x-default", at(config?.sourceLocale ?? listed[0])]);
|
|
33
|
+
return {
|
|
34
|
+
canonical: at(current),
|
|
35
|
+
links,
|
|
36
|
+
og: [current, ...listed.filter((l) => l !== current)].map(og)
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
//#endregion
|
|
40
|
+
export { alternates };
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
//#region src/server/index.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* `@menapo/seo/server` (PLAN D8): `withHead(request, render)` runs `render`
|
|
4
|
+
* in a scope of its own, where `head()` records the page's input, and
|
|
5
|
+
* writes the tags into the response's `<head>` as it streams. Its own
|
|
6
|
+
* entry, so browser bundles carry no `node:async_hooks`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* Renders `request` with `render`, then puts the page's head into the
|
|
10
|
+
* response: the plugin's defaults, the canonical, `og:url` and hreflang
|
|
11
|
+
* from the request's URL, and whatever `head()` set during the render. Only
|
|
12
|
+
* `text/html` bodies are touched, and only their `<head>`; a page that never
|
|
13
|
+
* called `head()` still gets the defaults and its URL.
|
|
14
|
+
*
|
|
15
|
+
* ```ts
|
|
16
|
+
* export const onRequest = (ctx, next) => withHead(ctx.request, next); // Astro
|
|
17
|
+
* export const handle = ({ event, resolve }) => withHead(event.request, () => resolve(event)); // SvelteKit
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
export declare function withHead(request: Request, render: () => Response | Promise<Response>): Promise<Response>;
|
|
21
|
+
//#endregion
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { n as tags } from "./tags-N6pWK3eY.js";
|
|
2
|
+
import { t as shared } from "./scope-B-mGqw0E.js";
|
|
3
|
+
import { r as rewrite, t as headEnd } from "./html-Cs8NzYrD.js";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
5
|
+
//#region src/server/index.ts
|
|
6
|
+
/**
|
|
7
|
+
* `@menapo/seo/server` (PLAN D8): `withHead(request, render)` runs `render`
|
|
8
|
+
* in a scope of its own, where `head()` records the page's input, and
|
|
9
|
+
* writes the tags into the response's `<head>` as it streams. Its own
|
|
10
|
+
* entry, so browser bundles carry no `node:async_hooks`.
|
|
11
|
+
*/
|
|
12
|
+
const DEFAULTS = typeof __SEO__ === "undefined" ? {} : __SEO__;
|
|
13
|
+
const global = shared();
|
|
14
|
+
const scopes = global.als ??= new AsyncLocalStorage();
|
|
15
|
+
global.scope ??= () => scopes.getStore();
|
|
16
|
+
/**
|
|
17
|
+
* Renders `request` with `render`, then puts the page's head into the
|
|
18
|
+
* response: the plugin's defaults, the canonical, `og:url` and hreflang
|
|
19
|
+
* from the request's URL, and whatever `head()` set during the render. Only
|
|
20
|
+
* `text/html` bodies are touched, and only their `<head>`; a page that never
|
|
21
|
+
* called `head()` still gets the defaults and its URL.
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* export const onRequest = (ctx, next) => withHead(ctx.request, next); // Astro
|
|
25
|
+
* export const handle = ({ event, resolve }) => withHead(event.request, () => resolve(event)); // SvelteKit
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
async function withHead(request, render) {
|
|
29
|
+
const scope = {
|
|
30
|
+
url: new URL(request.url),
|
|
31
|
+
input: void 0
|
|
32
|
+
};
|
|
33
|
+
const within = AsyncLocalStorage.snapshot();
|
|
34
|
+
const response = await scopes.run(scope, render);
|
|
35
|
+
if (!response.body || !/^text\/html\b/i.test(response.headers.get("content-type") ?? "")) return response;
|
|
36
|
+
const headers = new Headers(response.headers);
|
|
37
|
+
headers.delete("content-length");
|
|
38
|
+
return new Response(response.body.pipeThrough(inject(() => within(() => tags(scope.input ?? {}, DEFAULTS, scope.url)))), {
|
|
39
|
+
status: response.status,
|
|
40
|
+
statusText: response.statusText,
|
|
41
|
+
headers
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Holds the document back until `</head>`, rewrites it, then streams the
|
|
46
|
+
* rest untouched. The tags are taken at `</head>`, when a streaming
|
|
47
|
+
* renderer has run the shell, which is where a page's `head()` call runs.
|
|
48
|
+
*/
|
|
49
|
+
function inject(make) {
|
|
50
|
+
const decoder = new TextDecoder();
|
|
51
|
+
const encoder = new TextEncoder();
|
|
52
|
+
let held = "";
|
|
53
|
+
let done = false;
|
|
54
|
+
return new TransformStream({
|
|
55
|
+
transform(chunk, controller) {
|
|
56
|
+
if (done) return controller.enqueue(chunk);
|
|
57
|
+
held += decoder.decode(chunk, { stream: true });
|
|
58
|
+
if (headEnd(held) < 0) return;
|
|
59
|
+
done = true;
|
|
60
|
+
controller.enqueue(encoder.encode(rewrite(held, make())));
|
|
61
|
+
held = "";
|
|
62
|
+
},
|
|
63
|
+
flush(controller) {
|
|
64
|
+
held += decoder.decode();
|
|
65
|
+
if (held) controller.enqueue(encoder.encode(held));
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
export { withHead };
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { alternates } from "./locales/none.js";
|
|
2
|
+
//#region src/tags.ts
|
|
3
|
+
/**
|
|
4
|
+
* Input and defaults → the ordered tags of one page (PLAN §1.2): each fact
|
|
5
|
+
* written once and fanned out. Pure, shared by the browser, the server, the
|
|
6
|
+
* plugin's `index.html` step and `/test`, so all four write the same head.
|
|
7
|
+
*/
|
|
8
|
+
const SEPARATORS = /^[\s·|•–—:-]+|[\s·|•–—:-]+$/g;
|
|
9
|
+
/** An absolute http(s) URL, or an error that shows the value and the fix. */
|
|
10
|
+
function absolute(value, base, what) {
|
|
11
|
+
let url;
|
|
12
|
+
try {
|
|
13
|
+
url = new URL(value, base);
|
|
14
|
+
} catch {}
|
|
15
|
+
if (url?.protocol !== "https:" && url?.protocol !== "http:") throw new Error(`seo: ${what} ${JSON.stringify(String(value))} is not an absolute http(s) URL. Pass one, or set seo({ site }) to resolve it against.`);
|
|
16
|
+
return url;
|
|
17
|
+
}
|
|
18
|
+
let siteText;
|
|
19
|
+
let siteUrl;
|
|
20
|
+
const iso = (d) => typeof d === "string" ? d : d.toISOString();
|
|
21
|
+
/**
|
|
22
|
+
* `url` is the page's address (request, `location` or `input.url`), or
|
|
23
|
+
* undefined for site-wide tags (an SPA's `index.html`, PLAN D9).
|
|
24
|
+
*/
|
|
25
|
+
function tags(input, defaults, url) {
|
|
26
|
+
const out = [];
|
|
27
|
+
const meta = (key, k, content) => {
|
|
28
|
+
if (content !== void 0 && content !== "") out.push({
|
|
29
|
+
tag: "meta",
|
|
30
|
+
attrs: key === "name" ? {
|
|
31
|
+
name: k,
|
|
32
|
+
content: "" + content
|
|
33
|
+
} : {
|
|
34
|
+
property: k,
|
|
35
|
+
content: "" + content
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
if (defaults.site !== siteText) {
|
|
40
|
+
siteUrl = defaults.site ? absolute(defaults.site, void 0, "site") : void 0;
|
|
41
|
+
siteText = defaults.site;
|
|
42
|
+
}
|
|
43
|
+
const site = siteUrl;
|
|
44
|
+
if (input.url !== void 0) url = absolute(input.url, site, "url");
|
|
45
|
+
const page = url && site && url.origin !== site.origin ? new URL(url.pathname + url.search, site) : url;
|
|
46
|
+
const template = defaults.title;
|
|
47
|
+
const isTemplate = template?.includes("%s") ?? false;
|
|
48
|
+
const name = defaults.name ?? (isTemplate ? template.replace("%s", "").replace(SEPARATORS, "") : void 0);
|
|
49
|
+
const title = input.title !== void 0 ? isTemplate ? template.replace("%s", input.title) : input.title : isTemplate ? name : template;
|
|
50
|
+
if (title) out.push({
|
|
51
|
+
tag: "title",
|
|
52
|
+
attrs: {},
|
|
53
|
+
text: title
|
|
54
|
+
});
|
|
55
|
+
const description = input.description ?? defaults.description;
|
|
56
|
+
meta("name", "description", description);
|
|
57
|
+
meta("name", "robots", input.robots);
|
|
58
|
+
const alt = page && /* @__PURE__ */ alternates(page);
|
|
59
|
+
let canonical;
|
|
60
|
+
if (page) {
|
|
61
|
+
canonical = alt?.canonical ?? page.origin + page.pathname;
|
|
62
|
+
out.push({
|
|
63
|
+
tag: "link",
|
|
64
|
+
attrs: {
|
|
65
|
+
rel: "canonical",
|
|
66
|
+
href: canonical
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
for (const [hreflang, href] of alt?.links ?? []) out.push({
|
|
70
|
+
tag: "link",
|
|
71
|
+
attrs: {
|
|
72
|
+
rel: "alternate",
|
|
73
|
+
hreflang,
|
|
74
|
+
href
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
meta("property", "og:type", input.published !== void 0 || input.modified !== void 0 ? "article" : "website");
|
|
79
|
+
meta("property", "og:title", title);
|
|
80
|
+
meta("property", "og:description", description);
|
|
81
|
+
meta("property", "og:url", canonical);
|
|
82
|
+
meta("property", "og:site_name", name);
|
|
83
|
+
const [locale, ...others] = alt?.og ?? [];
|
|
84
|
+
meta("property", "og:locale", locale);
|
|
85
|
+
for (const l of others) meta("property", "og:locale:alternate", l);
|
|
86
|
+
const raw = input.image ?? defaults.image;
|
|
87
|
+
const image = typeof raw === "string" ? { url: raw } : raw;
|
|
88
|
+
if (image) {
|
|
89
|
+
meta("property", "og:image", absolute(image.url, page ?? site, "image").href);
|
|
90
|
+
meta("property", "og:image:width", image.width);
|
|
91
|
+
meta("property", "og:image:height", image.height);
|
|
92
|
+
meta("property", "og:image:alt", image.alt);
|
|
93
|
+
}
|
|
94
|
+
if (input.published !== void 0) meta("property", "article:published_time", iso(input.published));
|
|
95
|
+
if (input.modified !== void 0) meta("property", "article:modified_time", iso(input.modified));
|
|
96
|
+
meta("name", "twitter:card", image ? "summary_large_image" : "summary");
|
|
97
|
+
meta("name", "twitter:site", defaults.twitter);
|
|
98
|
+
if (input.jsonLd) out.push({
|
|
99
|
+
tag: "script",
|
|
100
|
+
attrs: { type: "application/ld+json" },
|
|
101
|
+
text: jsonLd(input.jsonLd)
|
|
102
|
+
});
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
/** `@context` added, several nodes as one `@graph`, escaped so data cannot close the script. */
|
|
106
|
+
function jsonLd(value) {
|
|
107
|
+
const doc = Array.isArray(value) ? {
|
|
108
|
+
"@context": "https://schema.org",
|
|
109
|
+
"@graph": value
|
|
110
|
+
} : {
|
|
111
|
+
"@context": "https://schema.org",
|
|
112
|
+
...value
|
|
113
|
+
};
|
|
114
|
+
return JSON.stringify(doc).replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* The identity a tag replaces another by (PLAN D3): repeated properties
|
|
118
|
+
* (`og:locale:alternate`) are told apart by their value. Undefined for a
|
|
119
|
+
* tag seo does not own.
|
|
120
|
+
*/
|
|
121
|
+
function keyOf(tag, attrs) {
|
|
122
|
+
if (tag === "title") return "title";
|
|
123
|
+
if (tag === "script") return attrs.type === "application/ld+json" ? "ld" : void 0;
|
|
124
|
+
if (tag === "link") {
|
|
125
|
+
if (attrs.rel === "canonical") return "canonical";
|
|
126
|
+
return attrs.rel === "alternate" && attrs.hreflang ? "h:" + attrs.hreflang : void 0;
|
|
127
|
+
}
|
|
128
|
+
if (tag !== "meta") return void 0;
|
|
129
|
+
const n = attrs.name;
|
|
130
|
+
if (n === "description" || n === "robots" || n?.startsWith("twitter:")) return "n:" + n;
|
|
131
|
+
const p = attrs.property;
|
|
132
|
+
if (p?.startsWith("og:") || p?.startsWith("article:")) return "p:" + p + (p === "og:locale:alternate" ? "=" + attrs.content : "");
|
|
133
|
+
}
|
|
134
|
+
//#endregion
|
|
135
|
+
export { tags as n, keyOf as t };
|
package/dist/test.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { a as Tag, i as SeoDefaults, t as HeadInput } from "./types-C_iXY4pr.js";
|
|
2
|
+
//#region src/test/index.d.ts
|
|
3
|
+
export interface HeadOfOptions {
|
|
4
|
+
/** The page's URL, as a request or `location` would give it. */
|
|
5
|
+
readonly url?: string | URL;
|
|
6
|
+
readonly defaults?: SeoDefaults;
|
|
7
|
+
}
|
|
8
|
+
/** The ordered tags `head(input)` writes at `options.url`. */
|
|
9
|
+
export declare function headOf(input?: HeadInput, options?: HeadOfOptions): Tag[];
|
|
10
|
+
/** The same tags as HTML, as the server writes them into `<head>`. */
|
|
11
|
+
export declare function headHtml(input?: HeadInput, options?: HeadOfOptions): string;
|
|
12
|
+
//#endregion
|
|
13
|
+
export type { Tag };
|
package/dist/test.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { n as tags } from "./tags-N6pWK3eY.js";
|
|
2
|
+
import { n as render } from "./html-Cs8NzYrD.js";
|
|
3
|
+
//#region src/test/index.ts
|
|
4
|
+
/**
|
|
5
|
+
* `@menapo/seo/test`: the tags one `head()` call writes, as data, for unit
|
|
6
|
+
* tests that assert a page's head without a DOM or a server. Defaults are
|
|
7
|
+
* passed in (a test's plugin config may differ from the app's); in a Vitest
|
|
8
|
+
* run with `seo()` in the config they default to the plugin's.
|
|
9
|
+
*/
|
|
10
|
+
/** The ordered tags `head(input)` writes at `options.url`. */
|
|
11
|
+
function headOf(input = {}, options = {}) {
|
|
12
|
+
const defaults = options.defaults ?? (typeof __SEO__ === "undefined" ? {} : __SEO__);
|
|
13
|
+
return tags(input, defaults, options.url === void 0 ? void 0 : new URL(options.url));
|
|
14
|
+
}
|
|
15
|
+
/** The same tags as HTML, as the server writes them into `<head>`. */
|
|
16
|
+
function headHtml(input = {}, options = {}) {
|
|
17
|
+
return render(headOf(input, options));
|
|
18
|
+
}
|
|
19
|
+
//#endregion
|
|
20
|
+
export { headHtml, headOf };
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
//#region src/types.d.ts
|
|
2
|
+
/** An image as `@menapo/og` returns it, or any `{ url }`; a string is its URL. */
|
|
3
|
+
interface Image {
|
|
4
|
+
readonly url: string;
|
|
5
|
+
readonly width?: number;
|
|
6
|
+
readonly height?: number;
|
|
7
|
+
readonly alt?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* One schema.org node. `schema-dts` types (`Product`, `Article` …) fit it,
|
|
11
|
+
* so `satisfies Product` gives completion without seo depending on them.
|
|
12
|
+
*/
|
|
13
|
+
interface JsonLd {
|
|
14
|
+
readonly "@type": string | readonly string[];
|
|
15
|
+
readonly [key: string]: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** What one page says about itself: `head(input)`. Every key is optional. */
|
|
18
|
+
interface HeadInput {
|
|
19
|
+
/** The page's title, put through the plugin's `title` template. */
|
|
20
|
+
readonly title?: string;
|
|
21
|
+
readonly description?: string;
|
|
22
|
+
/** A URL (relative to the page) or `{ url, width, height, alt }`. */
|
|
23
|
+
readonly image?: string | Image;
|
|
24
|
+
/** Makes the page an article; the sitemap's `lastmod` is `modified`, else this. */
|
|
25
|
+
readonly published?: Date | string;
|
|
26
|
+
readonly modified?: Date | string;
|
|
27
|
+
/** As Google documents it: `"noindex"` also keeps the page out of the sitemap. */
|
|
28
|
+
readonly robots?: string;
|
|
29
|
+
readonly jsonLd?: JsonLd | readonly JsonLd[];
|
|
30
|
+
/** The page's URL where there is no request or `location` to read it from. */
|
|
31
|
+
readonly url?: string | URL;
|
|
32
|
+
}
|
|
33
|
+
/** The plugin's options: defaults that each page's `head()` input replaces. */
|
|
34
|
+
interface SeoDefaults {
|
|
35
|
+
/** The public origin (and base path), for canonicals, the sitemap and robots.txt. Default: `SITE_URL`. */
|
|
36
|
+
readonly site?: string;
|
|
37
|
+
/** `"%s · Acme"` is a template (a page without a title gets `Acme`); a plain string is the default title. */
|
|
38
|
+
readonly title?: string;
|
|
39
|
+
readonly description?: string;
|
|
40
|
+
readonly image?: string | Image;
|
|
41
|
+
/** `og:site_name`. Default: the title template without `%s` and its separator. */
|
|
42
|
+
readonly name?: string;
|
|
43
|
+
/** `twitter:site`, such as `"@acme"`. */
|
|
44
|
+
readonly twitter?: string;
|
|
45
|
+
}
|
|
46
|
+
/** One head element, as `/test`'s `headOf()` returns it. */
|
|
47
|
+
interface Tag {
|
|
48
|
+
readonly tag: "title" | "meta" | "link" | "script";
|
|
49
|
+
readonly attrs: Readonly<Record<string, string>>;
|
|
50
|
+
/** The element's text: the title, or the JSON-LD. */
|
|
51
|
+
readonly text?: string;
|
|
52
|
+
}
|
|
53
|
+
//#endregion
|
|
54
|
+
export { Tag as a, SeoDefaults as i, Image as n, JsonLd as r, HeadInput as t };
|
package/dist/vite.d.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Plugin } from "vite";
|
|
2
|
+
//#region src/types.d.ts
|
|
3
|
+
/** An image as `@menapo/og` returns it, or any `{ url }`; a string is its URL. */
|
|
4
|
+
interface Image {
|
|
5
|
+
readonly url: string;
|
|
6
|
+
readonly width?: number;
|
|
7
|
+
readonly height?: number;
|
|
8
|
+
readonly alt?: string;
|
|
9
|
+
}
|
|
10
|
+
/** The plugin's options: defaults that each page's `head()` input replaces. */
|
|
11
|
+
interface SeoDefaults {
|
|
12
|
+
/** The public origin (and base path), for canonicals, the sitemap and robots.txt. Default: `SITE_URL`. */
|
|
13
|
+
readonly site?: string;
|
|
14
|
+
/** `"%s · Acme"` is a template (a page without a title gets `Acme`); a plain string is the default title. */
|
|
15
|
+
readonly title?: string;
|
|
16
|
+
readonly description?: string;
|
|
17
|
+
readonly image?: string | Image;
|
|
18
|
+
/** `og:site_name`. Default: the title template without `%s` and its separator. */
|
|
19
|
+
readonly name?: string;
|
|
20
|
+
/** `twitter:site`, such as `"@acme"`. */
|
|
21
|
+
readonly twitter?: string;
|
|
22
|
+
}
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region src/vite/index.d.ts
|
|
25
|
+
/** `plugins: [seo({ site, title: "%s · Acme", image })]` in a Vite config; every option is optional. */
|
|
26
|
+
export declare function seo(options?: SeoDefaults): Plugin;
|
|
27
|
+
//#endregion
|
|
28
|
+
export type { SeoDefaults };
|
package/dist/vite.js
ADDED
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
import { join, posix, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
//#endregion
|
|
5
|
+
//#region src/tags.ts
|
|
6
|
+
/**
|
|
7
|
+
* Input and defaults → the ordered tags of one page (PLAN §1.2): each fact
|
|
8
|
+
* written once and fanned out. Pure, shared by the browser, the server, the
|
|
9
|
+
* plugin's `index.html` step and `/test`, so all four write the same head.
|
|
10
|
+
*/
|
|
11
|
+
const SEPARATORS = /^[\s·|•–—:-]+|[\s·|•–—:-]+$/g;
|
|
12
|
+
/** An absolute http(s) URL, or an error that shows the value and the fix. */
|
|
13
|
+
function absolute(value, base, what) {
|
|
14
|
+
let url;
|
|
15
|
+
try {
|
|
16
|
+
url = new URL(value, base);
|
|
17
|
+
} catch {}
|
|
18
|
+
if (url?.protocol !== "https:" && url?.protocol !== "http:") throw new Error(`seo: ${what} ${JSON.stringify(String(value))} is not an absolute http(s) URL. Pass one, or set seo({ site }) to resolve it against.`);
|
|
19
|
+
return url;
|
|
20
|
+
}
|
|
21
|
+
let siteText;
|
|
22
|
+
let siteUrl;
|
|
23
|
+
const iso = (d) => typeof d === "string" ? d : d.toISOString();
|
|
24
|
+
/**
|
|
25
|
+
* `url` is the page's address (request, `location` or `input.url`), or
|
|
26
|
+
* undefined for site-wide tags (an SPA's `index.html`, PLAN D9).
|
|
27
|
+
*/
|
|
28
|
+
function tags(input, defaults, url) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const meta = (key, k, content) => {
|
|
31
|
+
if (content !== void 0 && content !== "") out.push({
|
|
32
|
+
tag: "meta",
|
|
33
|
+
attrs: key === "name" ? {
|
|
34
|
+
name: k,
|
|
35
|
+
content: "" + content
|
|
36
|
+
} : {
|
|
37
|
+
property: k,
|
|
38
|
+
content: "" + content
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
if (defaults.site !== siteText) {
|
|
43
|
+
siteUrl = defaults.site ? absolute(defaults.site, void 0, "site") : void 0;
|
|
44
|
+
siteText = defaults.site;
|
|
45
|
+
}
|
|
46
|
+
const site = siteUrl;
|
|
47
|
+
if (input.url !== void 0) url = absolute(input.url, site, "url");
|
|
48
|
+
const page = url && site && url.origin !== site.origin ? new URL(url.pathname + url.search, site) : url;
|
|
49
|
+
const template = defaults.title;
|
|
50
|
+
const isTemplate = template?.includes("%s") ?? false;
|
|
51
|
+
const name = defaults.name ?? (isTemplate ? template.replace("%s", "").replace(SEPARATORS, "") : void 0);
|
|
52
|
+
const title = input.title !== void 0 ? isTemplate ? template.replace("%s", input.title) : input.title : isTemplate ? name : template;
|
|
53
|
+
if (title) out.push({
|
|
54
|
+
tag: "title",
|
|
55
|
+
attrs: {},
|
|
56
|
+
text: title
|
|
57
|
+
});
|
|
58
|
+
const description = input.description ?? defaults.description;
|
|
59
|
+
meta("name", "description", description);
|
|
60
|
+
meta("name", "robots", input.robots);
|
|
61
|
+
const alt = page && void 0;
|
|
62
|
+
let canonical;
|
|
63
|
+
if (page) {
|
|
64
|
+
canonical = alt?.canonical ?? page.origin + page.pathname;
|
|
65
|
+
out.push({
|
|
66
|
+
tag: "link",
|
|
67
|
+
attrs: {
|
|
68
|
+
rel: "canonical",
|
|
69
|
+
href: canonical
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
for (const [hreflang, href] of alt?.links ?? []) out.push({
|
|
73
|
+
tag: "link",
|
|
74
|
+
attrs: {
|
|
75
|
+
rel: "alternate",
|
|
76
|
+
hreflang,
|
|
77
|
+
href
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
meta("property", "og:type", input.published !== void 0 || input.modified !== void 0 ? "article" : "website");
|
|
82
|
+
meta("property", "og:title", title);
|
|
83
|
+
meta("property", "og:description", description);
|
|
84
|
+
meta("property", "og:url", canonical);
|
|
85
|
+
meta("property", "og:site_name", name);
|
|
86
|
+
const [locale, ...others] = alt?.og ?? [];
|
|
87
|
+
meta("property", "og:locale", locale);
|
|
88
|
+
for (const l of others) meta("property", "og:locale:alternate", l);
|
|
89
|
+
const raw = input.image ?? defaults.image;
|
|
90
|
+
const image = typeof raw === "string" ? { url: raw } : raw;
|
|
91
|
+
if (image) {
|
|
92
|
+
meta("property", "og:image", absolute(image.url, page ?? site, "image").href);
|
|
93
|
+
meta("property", "og:image:width", image.width);
|
|
94
|
+
meta("property", "og:image:height", image.height);
|
|
95
|
+
meta("property", "og:image:alt", image.alt);
|
|
96
|
+
}
|
|
97
|
+
if (input.published !== void 0) meta("property", "article:published_time", iso(input.published));
|
|
98
|
+
if (input.modified !== void 0) meta("property", "article:modified_time", iso(input.modified));
|
|
99
|
+
meta("name", "twitter:card", image ? "summary_large_image" : "summary");
|
|
100
|
+
meta("name", "twitter:site", defaults.twitter);
|
|
101
|
+
if (input.jsonLd) out.push({
|
|
102
|
+
tag: "script",
|
|
103
|
+
attrs: { type: "application/ld+json" },
|
|
104
|
+
text: jsonLd(input.jsonLd)
|
|
105
|
+
});
|
|
106
|
+
return out;
|
|
107
|
+
}
|
|
108
|
+
/** `@context` added, several nodes as one `@graph`, escaped so data cannot close the script. */
|
|
109
|
+
function jsonLd(value) {
|
|
110
|
+
const doc = Array.isArray(value) ? {
|
|
111
|
+
"@context": "https://schema.org",
|
|
112
|
+
"@graph": value
|
|
113
|
+
} : {
|
|
114
|
+
"@context": "https://schema.org",
|
|
115
|
+
...value
|
|
116
|
+
};
|
|
117
|
+
return JSON.stringify(doc).replace(/[<>&\u2028\u2029]/g, (c) => "\\u" + c.charCodeAt(0).toString(16).padStart(4, "0"));
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* The identity a tag replaces another by (PLAN D3): repeated properties
|
|
121
|
+
* (`og:locale:alternate`) are told apart by their value. Undefined for a
|
|
122
|
+
* tag seo does not own.
|
|
123
|
+
*/
|
|
124
|
+
function keyOf(tag, attrs) {
|
|
125
|
+
if (tag === "title") return "title";
|
|
126
|
+
if (tag === "script") return attrs.type === "application/ld+json" ? "ld" : void 0;
|
|
127
|
+
if (tag === "link") {
|
|
128
|
+
if (attrs.rel === "canonical") return "canonical";
|
|
129
|
+
return attrs.rel === "alternate" && attrs.hreflang ? "h:" + attrs.hreflang : void 0;
|
|
130
|
+
}
|
|
131
|
+
if (tag !== "meta") return void 0;
|
|
132
|
+
const n = attrs.name;
|
|
133
|
+
if (n === "description" || n === "robots" || n?.startsWith("twitter:")) return "n:" + n;
|
|
134
|
+
const p = attrs.property;
|
|
135
|
+
if (p?.startsWith("og:") || p?.startsWith("article:")) return "p:" + p + (p === "og:locale:alternate" ? "=" + attrs.content : "");
|
|
136
|
+
}
|
|
137
|
+
//#endregion
|
|
138
|
+
//#region src/html.ts
|
|
139
|
+
/**
|
|
140
|
+
* Head HTML as text, for the server's response rewrite, the plugin's
|
|
141
|
+
* `index.html` step and the sitemap's crawl. Not a parser: it reads the
|
|
142
|
+
* `<title>`, `<meta>`, `<link>` and `<script>` elements of one `<head>`,
|
|
143
|
+
* which is all seo writes or needs to read.
|
|
144
|
+
*/
|
|
145
|
+
const ESCAPES = {
|
|
146
|
+
"&": "&",
|
|
147
|
+
"<": "<",
|
|
148
|
+
">": ">",
|
|
149
|
+
"\"": """
|
|
150
|
+
};
|
|
151
|
+
const ESCAPE = /[&<>"]/;
|
|
152
|
+
const escape = (s) => ESCAPE.test(s) ? s.replace(/[&<>"]/g, (c) => ESCAPES[c]) : s;
|
|
153
|
+
const ENTITIES = {
|
|
154
|
+
amp: "&",
|
|
155
|
+
lt: "<",
|
|
156
|
+
gt: ">",
|
|
157
|
+
quot: "\"",
|
|
158
|
+
"#39": "'",
|
|
159
|
+
apos: "'"
|
|
160
|
+
};
|
|
161
|
+
const unescape = (s) => s.replace(/&(amp|lt|gt|quot|#39|apos);/g, (_, e) => ENTITIES[e]);
|
|
162
|
+
/** The tags as HTML. JSON-LD is already escaped for a script body. */
|
|
163
|
+
function render(tags) {
|
|
164
|
+
let out = "";
|
|
165
|
+
for (const t of tags) {
|
|
166
|
+
let attrs = "";
|
|
167
|
+
for (const name in t.attrs) attrs += " " + name + "=\"" + escape(t.attrs[name]) + "\"";
|
|
168
|
+
const text = t.tag === "title" ? escape(t.text ?? "") : t.text ?? "";
|
|
169
|
+
out += t.tag === "meta" || t.tag === "link" ? `<${t.tag}${attrs}>` : `<${t.tag}${attrs}>${text}</${t.tag}>`;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
const ELEMENT = /<(title|script)\b([^>]*)>[\s\S]*?<\/\1\s*>|<(meta|link)\b([^>]*?)\/?>/gi;
|
|
174
|
+
const ATTR = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+)))?/g;
|
|
175
|
+
/** The elements seo reads in `head` (the text of one `<head>`). */
|
|
176
|
+
function scan(head) {
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const m of head.matchAll(ELEMENT)) {
|
|
179
|
+
const attrs = {};
|
|
180
|
+
for (const a of (m[2] ?? m[4] ?? "").matchAll(ATTR)) attrs[a[1].toLowerCase()] = unescape(a[2] ?? a[3] ?? a[4] ?? "");
|
|
181
|
+
out.push({
|
|
182
|
+
tag: (m[1] ?? m[3]).toLowerCase(),
|
|
183
|
+
attrs,
|
|
184
|
+
start: m.index,
|
|
185
|
+
end: m.index + m[0].length
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
const HEAD_END = /<\/head\s*>/i;
|
|
191
|
+
/** The index of `</head>` in `html`, or -1. */
|
|
192
|
+
const headEnd = (html) => html.search(HEAD_END);
|
|
193
|
+
/**
|
|
194
|
+
* `html` (a document up to and including `</head>`) with every element whose
|
|
195
|
+
* key one of `tags` has removed, and `tags` inserted before `</head>`.
|
|
196
|
+
*/
|
|
197
|
+
function rewrite(html, tags) {
|
|
198
|
+
const end = headEnd(html);
|
|
199
|
+
if (end < 0) return html;
|
|
200
|
+
const start = Math.max(0, html.search(/<head\b[^>]*>/i));
|
|
201
|
+
const keys = new Set(tags.map((t) => keyOf(t.tag, t.attrs)));
|
|
202
|
+
let out = "";
|
|
203
|
+
let at = start;
|
|
204
|
+
for (const f of scan(html.slice(start, end))) {
|
|
205
|
+
if (!keys.has(keyOf(f.tag, f.attrs))) continue;
|
|
206
|
+
out += html.slice(at, start + f.start);
|
|
207
|
+
at = start + f.end;
|
|
208
|
+
}
|
|
209
|
+
return html.slice(0, start) + out + html.slice(at, end) + render(tags) + html.slice(end);
|
|
210
|
+
}
|
|
211
|
+
//#endregion
|
|
212
|
+
//#region src/vite/sitemap.ts
|
|
213
|
+
/**
|
|
214
|
+
* `sitemap.xml` and `robots.txt` from the built pages (PLAN D10). Each page's
|
|
215
|
+
* `<head>` is the truth: `loc` is its canonical, its alternates are its
|
|
216
|
+
* hreflang links, `lastmod` its article dates. `noindex` pages, error pages
|
|
217
|
+
* and pages canonical to another URL are left out.
|
|
218
|
+
*/
|
|
219
|
+
/** Google's limit per sitemap file; past it the sitemap becomes an index. */
|
|
220
|
+
const LIMIT = 5e4;
|
|
221
|
+
const ERROR_PAGE = /^(?:404|500)(?:\.html|\/index\.html)$/;
|
|
222
|
+
function htmlFiles(dir) {
|
|
223
|
+
const out = [];
|
|
224
|
+
for (const d of readdirSync(dir, {
|
|
225
|
+
recursive: true,
|
|
226
|
+
withFileTypes: true
|
|
227
|
+
})) if (d.isFile() && d.name.endsWith(".html")) out.push(relative(dir, join(d.parentPath, d.name)).split(sep).join("/"));
|
|
228
|
+
return out.sort();
|
|
229
|
+
}
|
|
230
|
+
const trim = (path) => path.replace(/(?:\/index)?(?:\.html)?$/, "").replace(/\/$/, "");
|
|
231
|
+
/** The entries of every page under `dir`, plus the messages that would make the sitemap wrong. */
|
|
232
|
+
function crawl(dir, site, base) {
|
|
233
|
+
const entries = [];
|
|
234
|
+
const warnings = [];
|
|
235
|
+
const files = htmlFiles(dir);
|
|
236
|
+
for (const file of files) {
|
|
237
|
+
if (ERROR_PAGE.test(file)) continue;
|
|
238
|
+
const html = readFileSync(join(dir, file), "utf8");
|
|
239
|
+
const end = headEnd(html);
|
|
240
|
+
let canonical;
|
|
241
|
+
let noindex = false;
|
|
242
|
+
let modified;
|
|
243
|
+
let published;
|
|
244
|
+
const alternates = [];
|
|
245
|
+
for (const { tag, attrs } of scan(end < 0 ? "" : html.slice(0, end))) if (tag === "link" && attrs.rel === "canonical") canonical = attrs.href;
|
|
246
|
+
else if (tag === "link" && attrs.rel === "alternate" && attrs.hreflang && attrs.href) alternates.push([attrs.hreflang, attrs.href]);
|
|
247
|
+
else if (tag === "meta" && attrs.name === "robots") noindex = /\bnoindex\b/i.test(attrs.content ?? "");
|
|
248
|
+
else if (tag === "meta" && attrs.property === "article:modified_time") modified = attrs.content;
|
|
249
|
+
else if (tag === "meta" && attrs.property === "article:published_time") published = attrs.content;
|
|
250
|
+
if (noindex) continue;
|
|
251
|
+
const own = posix.join(base, file.replace(/(?:^|\/)index\.html$/, "/").replace(/\.html$/, ""));
|
|
252
|
+
let loc;
|
|
253
|
+
if (canonical) {
|
|
254
|
+
if (trim(new URL(canonical).pathname) !== trim(own)) continue;
|
|
255
|
+
loc = canonical;
|
|
256
|
+
} else if (site) loc = new URL(own, site).href;
|
|
257
|
+
else {
|
|
258
|
+
warnings.push(`${file} has no canonical and the site is unknown, so it is left out. Set seo({ site }) or SITE_URL.`);
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const lastmod = modified ?? published;
|
|
262
|
+
entries.push(lastmod ? {
|
|
263
|
+
loc,
|
|
264
|
+
lastmod,
|
|
265
|
+
alternates
|
|
266
|
+
} : {
|
|
267
|
+
loc,
|
|
268
|
+
alternates
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
const byLoc = new Map(entries.map((e) => [e.loc, e]));
|
|
272
|
+
for (const e of entries) for (const [hreflang, href] of e.alternates) {
|
|
273
|
+
const other = byLoc.get(href);
|
|
274
|
+
if (other && href !== e.loc && !other.alternates.some(([, h]) => h === e.loc)) warnings.push(`${e.loc} links hreflang="${hreflang}" to ${href}, which does not link back. Call head() on both pages with vi18n in the config, which writes both directions.`);
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
entries,
|
|
278
|
+
pages: files.length,
|
|
279
|
+
warnings
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const XML = {
|
|
283
|
+
"&": "&",
|
|
284
|
+
"<": "<",
|
|
285
|
+
">": ">",
|
|
286
|
+
"\"": """,
|
|
287
|
+
"'": "'"
|
|
288
|
+
};
|
|
289
|
+
const xml = (s) => s.replace(/[&<>"']/g, (c) => XML[c]);
|
|
290
|
+
function urlset(entries) {
|
|
291
|
+
let out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\" xmlns:xhtml=\"http://www.w3.org/1999/xhtml\">\n";
|
|
292
|
+
for (const e of entries) {
|
|
293
|
+
out += `<url><loc>${xml(e.loc)}</loc>`;
|
|
294
|
+
if (e.lastmod) out += `<lastmod>${xml(e.lastmod)}</lastmod>`;
|
|
295
|
+
for (const [hreflang, href] of e.alternates) out += `<xhtml:link rel="alternate" hreflang="${xml(hreflang)}" href="${xml(href)}"/>`;
|
|
296
|
+
out += "</url>\n";
|
|
297
|
+
}
|
|
298
|
+
return out + "</urlset>\n";
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Writes `sitemap.xml` (an index of `sitemap-N.xml` past 50,000 URLs) and,
|
|
302
|
+
* when the app has none, `robots.txt`. Returns the warnings, and the number
|
|
303
|
+
* of HTML pages found, so the plugin can wait for a framework that writes
|
|
304
|
+
* its pages later.
|
|
305
|
+
*/
|
|
306
|
+
function writeSitemap(dir, site, base = "/") {
|
|
307
|
+
if (!existsSync(dir)) return {
|
|
308
|
+
pages: 0,
|
|
309
|
+
warnings: []
|
|
310
|
+
};
|
|
311
|
+
const { entries, pages, warnings } = crawl(dir, site, base);
|
|
312
|
+
const origin = site ?? (entries[0] && new URL(entries[0].loc));
|
|
313
|
+
if (!entries.length || !origin) return {
|
|
314
|
+
pages,
|
|
315
|
+
warnings
|
|
316
|
+
};
|
|
317
|
+
const at = (file) => new URL(posix.join(base, file), origin).href;
|
|
318
|
+
if (entries.length <= LIMIT) writeFileSync(join(dir, "sitemap.xml"), urlset(entries));
|
|
319
|
+
else {
|
|
320
|
+
let index = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<sitemapindex xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n";
|
|
321
|
+
for (let i = 0; i * LIMIT < entries.length; i++) {
|
|
322
|
+
writeFileSync(join(dir, `sitemap-${i}.xml`), urlset(entries.slice(i * LIMIT, (i + 1) * LIMIT)));
|
|
323
|
+
index += `<sitemap><loc>${xml(at(`sitemap-${i}.xml`))}</loc></sitemap>\n`;
|
|
324
|
+
}
|
|
325
|
+
writeFileSync(join(dir, "sitemap.xml"), index + "</sitemapindex>\n");
|
|
326
|
+
}
|
|
327
|
+
const robots = join(dir, "robots.txt");
|
|
328
|
+
const line = `Sitemap: ${at("sitemap.xml")}`;
|
|
329
|
+
if (!existsSync(robots)) writeFileSync(robots, `User-agent: *\nAllow: /\n\n${line}\n`);
|
|
330
|
+
else if (!/^\s*sitemap:/im.test(readFileSync(robots, "utf8"))) warnings.push(`robots.txt has no Sitemap line. Add "${line}" to it.`);
|
|
331
|
+
return {
|
|
332
|
+
pages,
|
|
333
|
+
warnings
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
//#endregion
|
|
337
|
+
//#region src/vite/index.ts
|
|
338
|
+
/**
|
|
339
|
+
* `@menapo/seo/vite`: `seo(defaults)` (PLAN §1, §4). It hands the defaults
|
|
340
|
+
* to the runtime as the `__SEO__` define, swaps in the vi18n locale module
|
|
341
|
+
* when `vi18n()` is in the config (D7), writes the site-wide tags into an
|
|
342
|
+
* SPA's `index.html` (D9), and writes `sitemap.xml` and `robots.txt` from
|
|
343
|
+
* the built pages after the build (D10).
|
|
344
|
+
*/
|
|
345
|
+
const EXT = import.meta.url.endsWith(".ts") ? ".ts" : ".js";
|
|
346
|
+
const ROOT = new URL(EXT === ".ts" ? "../" : "./", import.meta.url);
|
|
347
|
+
const posix$1 = (path) => path.replaceAll("\\", "/");
|
|
348
|
+
const NONE = posix$1(fileURLToPath(new URL("locales/none" + EXT, ROOT)));
|
|
349
|
+
const VI18N = posix$1(fileURLToPath(new URL("locales/vi18n" + EXT, ROOT)));
|
|
350
|
+
function siteOf(value) {
|
|
351
|
+
if (!value) return void 0;
|
|
352
|
+
let url;
|
|
353
|
+
try {
|
|
354
|
+
url = new URL(value);
|
|
355
|
+
} catch {
|
|
356
|
+
throw new Error(`seo: site ${JSON.stringify(value)} is not an absolute URL. Pass seo({ site: "https://example.com" }).`);
|
|
357
|
+
}
|
|
358
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`seo: site ${JSON.stringify(value)} is not an http(s) URL.`);
|
|
359
|
+
return url;
|
|
360
|
+
}
|
|
361
|
+
/** `plugins: [seo({ site, title: "%s · Acme", image })]` in a Vite config; every option is optional. */
|
|
362
|
+
function seo(options = {}) {
|
|
363
|
+
const site = siteOf(options.site ?? process.env.SITE_URL);
|
|
364
|
+
const defaults = site ? {
|
|
365
|
+
...options,
|
|
366
|
+
site: site.href
|
|
367
|
+
} : options;
|
|
368
|
+
let config;
|
|
369
|
+
let vi18n = false;
|
|
370
|
+
let written = false;
|
|
371
|
+
let hooked = false;
|
|
372
|
+
const sitemap = () => {
|
|
373
|
+
const { pages, warnings } = writeSitemap(resolve(config.root, config.environments.client?.build.outDir ?? config.build.outDir), site, config.base.startsWith("/") ? config.base : "/");
|
|
374
|
+
for (const w of warnings) config.logger.warn(`seo: ${w}`);
|
|
375
|
+
return pages;
|
|
376
|
+
};
|
|
377
|
+
const write = () => {
|
|
378
|
+
written = sitemap() > 0;
|
|
379
|
+
};
|
|
380
|
+
const last = () => {
|
|
381
|
+
process.once("beforeExit", () => void (written || write()));
|
|
382
|
+
};
|
|
383
|
+
return {
|
|
384
|
+
name: "seo",
|
|
385
|
+
config: () => ({
|
|
386
|
+
define: { __SEO__: JSON.stringify(defaults) },
|
|
387
|
+
optimizeDeps: { exclude: [
|
|
388
|
+
"@menapo/seo",
|
|
389
|
+
"@menapo/seo/server",
|
|
390
|
+
"@menapo/seo/test"
|
|
391
|
+
] },
|
|
392
|
+
ssr: { noExternal: ["@menapo/seo"] }
|
|
393
|
+
}),
|
|
394
|
+
configResolved(resolved) {
|
|
395
|
+
config = resolved;
|
|
396
|
+
vi18n = resolved.plugins.some((p) => p.name === "vi18n:config");
|
|
397
|
+
},
|
|
398
|
+
load: {
|
|
399
|
+
filter: { id: /locales[\\/]none\.[jt]s$/ },
|
|
400
|
+
handler(id) {
|
|
401
|
+
if (!vi18n || posix$1(id.replace(/[?#].*$/, "")) !== NONE) return null;
|
|
402
|
+
return `export { alternates } from ${JSON.stringify(VI18N)};`;
|
|
403
|
+
}
|
|
404
|
+
},
|
|
405
|
+
transformIndexHtml(html) {
|
|
406
|
+
return rewrite(html, tags({}, defaults, void 0));
|
|
407
|
+
},
|
|
408
|
+
closeBundle() {
|
|
409
|
+
if (config.command !== "build" || this.environment.config.consumer !== "client") return;
|
|
410
|
+
if (hooked) write();
|
|
411
|
+
else last();
|
|
412
|
+
},
|
|
413
|
+
buildApp: {
|
|
414
|
+
order: "post",
|
|
415
|
+
handler: async (builder) => {
|
|
416
|
+
hooked = true;
|
|
417
|
+
if (builder.environments.client?.isBuilt) {
|
|
418
|
+
hooked = false;
|
|
419
|
+
write();
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
//#endregion
|
|
426
|
+
export { seo };
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@menapo/seo",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Head tags, hreflang and sitemaps for kit apps: one head({ title, description, image }) call per page, in every framework",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"head",
|
|
7
|
+
"meta",
|
|
8
|
+
"open-graph",
|
|
9
|
+
"seo",
|
|
10
|
+
"sitemap",
|
|
11
|
+
"vite",
|
|
12
|
+
"vite-plugin"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/menapo/kit/blob/main/packages/seo/README.md",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/menapo/kit/issues"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/menapo/kit.git",
|
|
22
|
+
"directory": "packages/seo"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist"
|
|
26
|
+
],
|
|
27
|
+
"type": "module",
|
|
28
|
+
"sideEffects": false,
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./dist/index.js",
|
|
31
|
+
"./server": "./dist/server.js",
|
|
32
|
+
"./test": "./dist/test.js",
|
|
33
|
+
"./vite": "./dist/vite.js",
|
|
34
|
+
"./package.json": "./package.json"
|
|
35
|
+
},
|
|
36
|
+
"publishConfig": {
|
|
37
|
+
"access": "public"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@menapo/vi18n": "0.1.1",
|
|
41
|
+
"@playwright/test": "^1.64.1",
|
|
42
|
+
"@types/node": "^26.6.5",
|
|
43
|
+
"typescript": "^7.0.2",
|
|
44
|
+
"vite": "npm:@voidzero-dev/vite-plus-core@1.3.0",
|
|
45
|
+
"vite-plus": "1.3.0"
|
|
46
|
+
},
|
|
47
|
+
"peerDependencies": {
|
|
48
|
+
"@menapo/vi18n": "^0.1.1",
|
|
49
|
+
"vite": "^8.3.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"@menapo/vi18n": {
|
|
53
|
+
"optional": true
|
|
54
|
+
},
|
|
55
|
+
"vite": {
|
|
56
|
+
"optional": true
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"engines": {
|
|
60
|
+
"node": ">=22.18.0"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "vp pack",
|
|
64
|
+
"dev": "vp pack --watch",
|
|
65
|
+
"check": "vp check",
|
|
66
|
+
"test": "vp test run",
|
|
67
|
+
"smoke:packed": "node scripts/smoke-packed.ts"
|
|
68
|
+
}
|
|
6
69
|
}
|