@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 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
- # Temporary Holding Version
1
+ # @menapo/seo
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ "&": "&amp;",
11
+ "<": "&lt;",
12
+ ">": "&gt;",
13
+ "\"": "&quot;"
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 };
@@ -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,4 @@
1
+ //#region src/locales/none.ts
2
+ function alternates(_url) {}
3
+ //#endregion
4
+ export { alternates };
@@ -0,0 +1,5 @@
1
+ import { Alternates } from "./none.js";
2
+ //#region src/locales/vi18n.d.ts
3
+ export declare function alternates(url: URL): Alternates;
4
+ //#endregion
5
+ export type { Alternates };
@@ -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 };
@@ -0,0 +1,4 @@
1
+ //#region src/scope.ts
2
+ const shared = () => globalThis[Symbol.for("menapo.seo")] ??= {};
3
+ //#endregion
4
+ export { shared as t };
@@ -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
+ "&": "&amp;",
147
+ "<": "&lt;",
148
+ ">": "&gt;",
149
+ "\"": "&quot;"
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
+ "&": "&amp;",
284
+ "<": "&lt;",
285
+ ">": "&gt;",
286
+ "\"": "&quot;",
287
+ "'": "&apos;"
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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
  }