@hezaerd/seo 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 +62 -0
- package/dist/core.d.ts +75 -0
- package/dist/core.js +76 -0
- package/dist/tanstack-router.d.ts +40 -0
- package/dist/tanstack-router.js +60 -0
- package/package.json +60 -0
- package/src/astro/Seo.astro +41 -0
- package/src/astro/Seo.astro.d.ts +7 -0
- package/src/core.ts +186 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Hezaerd
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# @hezaerd/seo
|
|
2
|
+
|
|
3
|
+
Shared SEO metadata for Astro and TanStack Start, with a framework-independent TypeScript core.
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
bun add @hezaerd/seo
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Astro
|
|
10
|
+
|
|
11
|
+
Render inside your layout's `<head>`:
|
|
12
|
+
|
|
13
|
+
```astro
|
|
14
|
+
---
|
|
15
|
+
import Seo from '@hezaerd/seo/astro';
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
<Seo
|
|
19
|
+
title="Projects"
|
|
20
|
+
description="Selected work"
|
|
21
|
+
url="https://example.com/projects"
|
|
22
|
+
/>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## TanStack Start / Router
|
|
26
|
+
|
|
27
|
+
Return metadata from your route's `head` callback:
|
|
28
|
+
|
|
29
|
+
```tsx
|
|
30
|
+
import { createFileRoute } from '@tanstack/react-router';
|
|
31
|
+
import { seo } from '@hezaerd/seo/tanstack-router';
|
|
32
|
+
|
|
33
|
+
export const Route = createFileRoute('/')({
|
|
34
|
+
head: () => seo({
|
|
35
|
+
title: 'Home',
|
|
36
|
+
description: 'Selected work',
|
|
37
|
+
url: 'https://example.com/',
|
|
38
|
+
}),
|
|
39
|
+
component: HomePage,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function HomePage() {
|
|
43
|
+
return <main>Welcome</main>;
|
|
44
|
+
}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Your root layout must render TanStack's `HeadContent` in `<head>` as usual.
|
|
48
|
+
|
|
49
|
+
Both adapters accept the same options, including `image`, `imageAlt`, `siteName`, `locale`, `robots`, and `jsonLd`. Pass absolute URLs (strings or `URL` objects); `url` also supplies the canonical URL unless `canonical` is set. Public types and helpers are available from `@hezaerd/seo/core`.
|
|
50
|
+
|
|
51
|
+
## Development
|
|
52
|
+
|
|
53
|
+
```sh
|
|
54
|
+
bun install
|
|
55
|
+
bun run check
|
|
56
|
+
bun test
|
|
57
|
+
bun run build
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
The build emits ESM and TypeScript declarations with tsdown and validates the package with publint and Are the Types Wrong.
|
|
61
|
+
|
|
62
|
+
Publishing follows the analytics package: pushes to `master` publish new versions through `.github/workflows/publish.yml`; already-published versions are skipped. For the first release, publish the package from an authenticated npm account, then configure npm trusted publishing for `hezaerd-co/seo` and workflow `publish.yml` before enabling releases. The workflow also supports manual runs on `master`.
|
package/dist/core.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
//#region src/core.d.ts
|
|
2
|
+
/** A URL accepted by the SEO helpers. */
|
|
3
|
+
export type SeoUrl = string | URL;
|
|
4
|
+
/** Values accepted by JSON-LD documents. */
|
|
5
|
+
export type JsonLdValue = string | number | boolean | null | readonly JsonLdValue[] | {
|
|
6
|
+
readonly [key: string]: JsonLdValue | undefined;
|
|
7
|
+
};
|
|
8
|
+
export type OpenGraphType = "website" | "article" | "profile" | "book" | "music.song" | "music.album" | "music.playlist" | "music.radio_status" | "video.movie" | "video.episode" | "video.tv_show" | "video.other" | (string & {});
|
|
9
|
+
export type TwitterCard = "summary" | "summary_large_image" | "app" | "player" | (string & {});
|
|
10
|
+
/**
|
|
11
|
+
* Framework-independent values used to build a page's SEO metadata.
|
|
12
|
+
*
|
|
13
|
+
* Empty strings are ignored by {@link buildSeo}. URL objects are converted to
|
|
14
|
+
* their `href` value so the result can be passed to any renderer.
|
|
15
|
+
*/
|
|
16
|
+
export interface SeoOptions {
|
|
17
|
+
/** The document title. */
|
|
18
|
+
title?: string;
|
|
19
|
+
/** The document description. */
|
|
20
|
+
description?: string;
|
|
21
|
+
/** The page URL, used for `og:url` and the canonical link by default. */
|
|
22
|
+
url?: SeoUrl;
|
|
23
|
+
/** An explicit canonical URL when it differs from `url`. */
|
|
24
|
+
canonical?: SeoUrl;
|
|
25
|
+
/** The social sharing image URL. */
|
|
26
|
+
image?: SeoUrl;
|
|
27
|
+
/** Alternative text for the social sharing image. */
|
|
28
|
+
imageAlt?: string;
|
|
29
|
+
/** The site name used by Open Graph. */
|
|
30
|
+
siteName?: string;
|
|
31
|
+
/** Open Graph object type. Defaults to `website`. */
|
|
32
|
+
type?: OpenGraphType;
|
|
33
|
+
/** Open Graph locale, for example `en_US`. */
|
|
34
|
+
locale?: string;
|
|
35
|
+
/** Twitter card type. Defaults to `summary_large_image` when an image exists. */
|
|
36
|
+
twitterCard?: TwitterCard;
|
|
37
|
+
/** The value for the `robots` meta tag. */
|
|
38
|
+
robots?: string;
|
|
39
|
+
/** Add `noindex` to the robots directives. */
|
|
40
|
+
noIndex?: boolean;
|
|
41
|
+
/** Add `nofollow` to the robots directives. */
|
|
42
|
+
noFollow?: boolean;
|
|
43
|
+
/** One JSON-LD document, or several documents to render. */
|
|
44
|
+
jsonLd?: JsonLdValue | readonly JsonLdValue[];
|
|
45
|
+
}
|
|
46
|
+
/** Normalized SEO data shared by the framework adapters. */
|
|
47
|
+
export interface BuiltSeo {
|
|
48
|
+
title?: string;
|
|
49
|
+
description?: string;
|
|
50
|
+
url?: string;
|
|
51
|
+
canonical?: string;
|
|
52
|
+
image?: string;
|
|
53
|
+
imageAlt?: string;
|
|
54
|
+
siteName?: string;
|
|
55
|
+
type: OpenGraphType;
|
|
56
|
+
locale?: string;
|
|
57
|
+
twitterCard: TwitterCard;
|
|
58
|
+
robots?: string;
|
|
59
|
+
jsonLd: readonly JsonLdValue[];
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Normalize SEO options once before passing them to a framework adapter.
|
|
63
|
+
*
|
|
64
|
+
* This function has no framework or browser dependencies and is safe to call
|
|
65
|
+
* during server rendering.
|
|
66
|
+
*/
|
|
67
|
+
export declare function buildSeo(options?: SeoOptions): BuiltSeo;
|
|
68
|
+
/**
|
|
69
|
+
* Serialize JSON-LD for an inline HTML script safely.
|
|
70
|
+
*
|
|
71
|
+
* HTML-significant characters are escaped after JSON encoding, preventing a
|
|
72
|
+
* value such as `</script>` from closing the containing script element.
|
|
73
|
+
*/
|
|
74
|
+
export declare function serializeJsonLd(value: JsonLdValue): string;
|
|
75
|
+
//#endregion
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
//#region src/core.ts
|
|
2
|
+
function normalizeText(value) {
|
|
3
|
+
if (value === void 0) return void 0;
|
|
4
|
+
const normalized = value.trim();
|
|
5
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
6
|
+
}
|
|
7
|
+
function normalizeUrl(value) {
|
|
8
|
+
if (value === void 0) return void 0;
|
|
9
|
+
const normalized = typeof value === "string" ? value.trim() : value.href;
|
|
10
|
+
return normalized.length > 0 ? normalized : void 0;
|
|
11
|
+
}
|
|
12
|
+
function normalizeRobots(options) {
|
|
13
|
+
const directives = normalizeText(options.robots)?.split(",").map((directive) => directive.trim()).filter(Boolean) ?? [];
|
|
14
|
+
if (options.noIndex) {
|
|
15
|
+
for (let index = directives.length - 1; index >= 0; index -= 1) {
|
|
16
|
+
const directive = directives[index]?.toLowerCase();
|
|
17
|
+
if (directive === "index" || directive === "noindex") directives.splice(index, 1);
|
|
18
|
+
}
|
|
19
|
+
directives.push("noindex");
|
|
20
|
+
}
|
|
21
|
+
if (options.noFollow) {
|
|
22
|
+
for (let index = directives.length - 1; index >= 0; index -= 1) {
|
|
23
|
+
const directive = directives[index]?.toLowerCase();
|
|
24
|
+
if (directive === "follow" || directive === "nofollow") directives.splice(index, 1);
|
|
25
|
+
}
|
|
26
|
+
directives.push("nofollow");
|
|
27
|
+
}
|
|
28
|
+
return directives.length > 0 ? directives.join(", ") : void 0;
|
|
29
|
+
}
|
|
30
|
+
function normalizeJsonLd(value) {
|
|
31
|
+
if (value === void 0) return [];
|
|
32
|
+
return Array.isArray(value) ? value : [value];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalize SEO options once before passing them to a framework adapter.
|
|
36
|
+
*
|
|
37
|
+
* This function has no framework or browser dependencies and is safe to call
|
|
38
|
+
* during server rendering.
|
|
39
|
+
*/
|
|
40
|
+
function buildSeo(options = {}) {
|
|
41
|
+
const url = normalizeUrl(options.url);
|
|
42
|
+
const canonical = normalizeUrl(options.canonical ?? options.url);
|
|
43
|
+
const image = normalizeUrl(options.image);
|
|
44
|
+
return {
|
|
45
|
+
title: normalizeText(options.title),
|
|
46
|
+
description: normalizeText(options.description),
|
|
47
|
+
url,
|
|
48
|
+
canonical,
|
|
49
|
+
image,
|
|
50
|
+
imageAlt: normalizeText(options.imageAlt),
|
|
51
|
+
siteName: normalizeText(options.siteName),
|
|
52
|
+
type: options.type ?? "website",
|
|
53
|
+
locale: normalizeText(options.locale),
|
|
54
|
+
twitterCard: options.twitterCard ?? (image ? "summary_large_image" : "summary"),
|
|
55
|
+
robots: normalizeRobots(options),
|
|
56
|
+
jsonLd: normalizeJsonLd(options.jsonLd)
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function escapeJsonForHtml(value) {
|
|
60
|
+
return value.replace(/</g, "\\u003c").replace(/>/g, "\\u003e").replace(/&/g, "\\u0026").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Serialize JSON-LD for an inline HTML script safely.
|
|
64
|
+
*
|
|
65
|
+
* HTML-significant characters are escaped after JSON encoding, preventing a
|
|
66
|
+
* value such as `<\/script>` from closing the containing script element.
|
|
67
|
+
*/
|
|
68
|
+
function serializeJsonLd(value) {
|
|
69
|
+
try {
|
|
70
|
+
return escapeJsonForHtml(JSON.stringify(value) ?? "null");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
throw new TypeError("JSON-LD data must be JSON serializable", { cause: error });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
//#endregion
|
|
76
|
+
export { buildSeo, serializeJsonLd };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { SeoOptions } from "./core.js";
|
|
2
|
+
//#region src/tanstack-router.d.ts
|
|
3
|
+
/** A metadata descriptor understood by TanStack Router's `head` option. */
|
|
4
|
+
export type SeoMetaDescriptor = {
|
|
5
|
+
title: string;
|
|
6
|
+
} | {
|
|
7
|
+
name: string;
|
|
8
|
+
content: string;
|
|
9
|
+
} | {
|
|
10
|
+
property: string;
|
|
11
|
+
content: string;
|
|
12
|
+
};
|
|
13
|
+
/** A link descriptor understood by TanStack Router's `head` option. */
|
|
14
|
+
export interface SeoLinkDescriptor {
|
|
15
|
+
rel: string;
|
|
16
|
+
href?: string;
|
|
17
|
+
[attribute: string]: string | undefined;
|
|
18
|
+
}
|
|
19
|
+
/** An inline script descriptor understood by TanStack Router's `head` option. */
|
|
20
|
+
export interface SeoScriptDescriptor {
|
|
21
|
+
type?: string;
|
|
22
|
+
children?: string;
|
|
23
|
+
src?: string;
|
|
24
|
+
[attribute: string]: string | undefined;
|
|
25
|
+
}
|
|
26
|
+
/** The native-shaped value returned from {@link seo}. */
|
|
27
|
+
export interface TanStackSeoHead {
|
|
28
|
+
meta: SeoMetaDescriptor[];
|
|
29
|
+
links?: SeoLinkDescriptor[];
|
|
30
|
+
scripts?: SeoScriptDescriptor[];
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Build a TanStack Router route `head` value from shared SEO options.
|
|
34
|
+
*
|
|
35
|
+
* The returned object is framework-shaped but has no runtime dependency on
|
|
36
|
+
* TanStack Router, so it also works with TanStack Start's route definitions.
|
|
37
|
+
*/
|
|
38
|
+
export declare function seo(options?: SeoOptions): TanStackSeoHead;
|
|
39
|
+
//#endregion
|
|
40
|
+
export type { SeoOptions };
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { buildSeo, serializeJsonLd } from "./core.js";
|
|
2
|
+
//#region src/tanstack-router.ts
|
|
3
|
+
function addMeta(meta, name, content) {
|
|
4
|
+
if (content !== void 0) meta.push({
|
|
5
|
+
name,
|
|
6
|
+
content
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function addProperty(meta, property, content) {
|
|
10
|
+
if (content !== void 0) meta.push({
|
|
11
|
+
property,
|
|
12
|
+
content
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Build a TanStack Router route `head` value from shared SEO options.
|
|
17
|
+
*
|
|
18
|
+
* The returned object is framework-shaped but has no runtime dependency on
|
|
19
|
+
* TanStack Router, so it also works with TanStack Start's route definitions.
|
|
20
|
+
*/
|
|
21
|
+
function seo(options = {}) {
|
|
22
|
+
const data = buildSeo(options);
|
|
23
|
+
const meta = [];
|
|
24
|
+
const pageUrl = data.url ?? data.canonical;
|
|
25
|
+
if (data.title !== void 0) {
|
|
26
|
+
meta.push({ title: data.title });
|
|
27
|
+
addProperty(meta, "og:title", data.title);
|
|
28
|
+
addMeta(meta, "twitter:title", data.title);
|
|
29
|
+
}
|
|
30
|
+
addMeta(meta, "description", data.description);
|
|
31
|
+
addProperty(meta, "og:description", data.description);
|
|
32
|
+
addMeta(meta, "twitter:description", data.description);
|
|
33
|
+
addProperty(meta, "og:url", pageUrl);
|
|
34
|
+
addProperty(meta, "og:type", data.type);
|
|
35
|
+
addProperty(meta, "og:site_name", data.siteName);
|
|
36
|
+
addProperty(meta, "og:locale", data.locale);
|
|
37
|
+
addMeta(meta, "twitter:card", data.twitterCard);
|
|
38
|
+
addMeta(meta, "robots", data.robots);
|
|
39
|
+
if (data.image !== void 0) {
|
|
40
|
+
addProperty(meta, "og:image", data.image);
|
|
41
|
+
addMeta(meta, "twitter:image", data.image);
|
|
42
|
+
addProperty(meta, "og:image:alt", data.imageAlt);
|
|
43
|
+
addMeta(meta, "twitter:image:alt", data.imageAlt);
|
|
44
|
+
}
|
|
45
|
+
const links = data.canonical ? [{
|
|
46
|
+
rel: "canonical",
|
|
47
|
+
href: data.canonical
|
|
48
|
+
}] : void 0;
|
|
49
|
+
const scripts = data.jsonLd.length ? data.jsonLd.map((document) => ({
|
|
50
|
+
type: "application/ld+json",
|
|
51
|
+
children: serializeJsonLd(document)
|
|
52
|
+
})) : void 0;
|
|
53
|
+
return {
|
|
54
|
+
meta,
|
|
55
|
+
...links && { links },
|
|
56
|
+
...scripts && { scripts }
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { seo };
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hezaerd/seo",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A small, typed SEO metadata helper for Astro and TanStack Router",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": false,
|
|
7
|
+
"files": ["dist", "src/astro", "src/core.ts"],
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/core.d.ts",
|
|
11
|
+
"import": "./dist/core.js"
|
|
12
|
+
},
|
|
13
|
+
"./core": {
|
|
14
|
+
"types": "./dist/core.d.ts",
|
|
15
|
+
"import": "./dist/core.js"
|
|
16
|
+
},
|
|
17
|
+
"./tanstack-router": {
|
|
18
|
+
"types": "./dist/tanstack-router.d.ts",
|
|
19
|
+
"import": "./dist/tanstack-router.js"
|
|
20
|
+
},
|
|
21
|
+
"./astro": {
|
|
22
|
+
"types": "./src/astro/Seo.astro.d.ts",
|
|
23
|
+
"default": "./src/astro/Seo.astro"
|
|
24
|
+
},
|
|
25
|
+
"./package.json": "./package.json"
|
|
26
|
+
},
|
|
27
|
+
"scripts": {
|
|
28
|
+
"build": "tsdown",
|
|
29
|
+
"check": "tsc --noEmit && astro check",
|
|
30
|
+
"test": "bun test --pass-with-no-tests",
|
|
31
|
+
"prepublishOnly": "bun run check && bun run test && bun run build"
|
|
32
|
+
},
|
|
33
|
+
"keywords": ["seo", "astro", "tanstack", "tanstack-router"],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/hezaerd-co/seo.git"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@tanstack/react-router": "^1.0.0",
|
|
44
|
+
"astro": "^7.0.0"
|
|
45
|
+
},
|
|
46
|
+
"peerDependenciesMeta": {
|
|
47
|
+
"@tanstack/react-router": { "optional": true },
|
|
48
|
+
"astro": { "optional": true }
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@arethetypeswrong/cli": "^0.18.5",
|
|
52
|
+
"@astrojs/check": "^0.9.10",
|
|
53
|
+
"@tanstack/react-router": "^1.170.35",
|
|
54
|
+
"@types/bun": "^1.4.2",
|
|
55
|
+
"astro": "^7.3.2",
|
|
56
|
+
"publint": "^0.3.24",
|
|
57
|
+
"tsdown": "^0.23.0",
|
|
58
|
+
"typescript": "^6.0.3"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
---
|
|
2
|
+
import {
|
|
3
|
+
buildSeo,
|
|
4
|
+
serializeJsonLd,
|
|
5
|
+
type SeoOptions,
|
|
6
|
+
} from "../core";
|
|
7
|
+
|
|
8
|
+
export interface Props extends SeoOptions {}
|
|
9
|
+
|
|
10
|
+
const data = buildSeo(Astro.props);
|
|
11
|
+
const pageUrl = data.url ?? data.canonical;
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
{data.title && <title>{data.title}</title>}
|
|
15
|
+
{data.description && <meta name="description" content={data.description} />}
|
|
16
|
+
{data.canonical && <link rel="canonical" href={data.canonical} />}
|
|
17
|
+
{data.robots && <meta name="robots" content={data.robots} />}
|
|
18
|
+
|
|
19
|
+
{data.title && <meta property="og:title" content={data.title} />}
|
|
20
|
+
{data.description && <meta property="og:description" content={data.description} />}
|
|
21
|
+
{pageUrl && <meta property="og:url" content={pageUrl} />}
|
|
22
|
+
<meta property="og:type" content={data.type} />
|
|
23
|
+
{data.siteName && <meta property="og:site_name" content={data.siteName} />}
|
|
24
|
+
{data.locale && <meta property="og:locale" content={data.locale} />}
|
|
25
|
+
|
|
26
|
+
<meta name="twitter:card" content={data.twitterCard} />
|
|
27
|
+
{data.title && <meta name="twitter:title" content={data.title} />}
|
|
28
|
+
{data.description && <meta name="twitter:description" content={data.description} />}
|
|
29
|
+
|
|
30
|
+
{data.image && <meta property="og:image" content={data.image} />}
|
|
31
|
+
{data.image && data.imageAlt && <meta property="og:image:alt" content={data.imageAlt} />}
|
|
32
|
+
{data.image && <meta name="twitter:image" content={data.image} />}
|
|
33
|
+
{data.image && data.imageAlt && <meta name="twitter:image:alt" content={data.imageAlt} />}
|
|
34
|
+
|
|
35
|
+
{data.jsonLd.map((document) => (
|
|
36
|
+
<script
|
|
37
|
+
type="application/ld+json"
|
|
38
|
+
is:inline
|
|
39
|
+
set:html={serializeJsonLd(document)}
|
|
40
|
+
></script>
|
|
41
|
+
))}
|
package/src/core.ts
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** A URL accepted by the SEO helpers. */
|
|
2
|
+
export type SeoUrl = string | URL;
|
|
3
|
+
|
|
4
|
+
/** Values accepted by JSON-LD documents. */
|
|
5
|
+
export type JsonLdValue =
|
|
6
|
+
| string
|
|
7
|
+
| number
|
|
8
|
+
| boolean
|
|
9
|
+
| null
|
|
10
|
+
| readonly JsonLdValue[]
|
|
11
|
+
| { readonly [key: string]: JsonLdValue | undefined };
|
|
12
|
+
|
|
13
|
+
export type OpenGraphType =
|
|
14
|
+
| "website"
|
|
15
|
+
| "article"
|
|
16
|
+
| "profile"
|
|
17
|
+
| "book"
|
|
18
|
+
| "music.song"
|
|
19
|
+
| "music.album"
|
|
20
|
+
| "music.playlist"
|
|
21
|
+
| "music.radio_status"
|
|
22
|
+
| "video.movie"
|
|
23
|
+
| "video.episode"
|
|
24
|
+
| "video.tv_show"
|
|
25
|
+
| "video.other"
|
|
26
|
+
| (string & {});
|
|
27
|
+
|
|
28
|
+
export type TwitterCard =
|
|
29
|
+
| "summary"
|
|
30
|
+
| "summary_large_image"
|
|
31
|
+
| "app"
|
|
32
|
+
| "player"
|
|
33
|
+
| (string & {});
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Framework-independent values used to build a page's SEO metadata.
|
|
37
|
+
*
|
|
38
|
+
* Empty strings are ignored by {@link buildSeo}. URL objects are converted to
|
|
39
|
+
* their `href` value so the result can be passed to any renderer.
|
|
40
|
+
*/
|
|
41
|
+
export interface SeoOptions {
|
|
42
|
+
/** The document title. */
|
|
43
|
+
title?: string;
|
|
44
|
+
/** The document description. */
|
|
45
|
+
description?: string;
|
|
46
|
+
/** The page URL, used for `og:url` and the canonical link by default. */
|
|
47
|
+
url?: SeoUrl;
|
|
48
|
+
/** An explicit canonical URL when it differs from `url`. */
|
|
49
|
+
canonical?: SeoUrl;
|
|
50
|
+
/** The social sharing image URL. */
|
|
51
|
+
image?: SeoUrl;
|
|
52
|
+
/** Alternative text for the social sharing image. */
|
|
53
|
+
imageAlt?: string;
|
|
54
|
+
/** The site name used by Open Graph. */
|
|
55
|
+
siteName?: string;
|
|
56
|
+
/** Open Graph object type. Defaults to `website`. */
|
|
57
|
+
type?: OpenGraphType;
|
|
58
|
+
/** Open Graph locale, for example `en_US`. */
|
|
59
|
+
locale?: string;
|
|
60
|
+
/** Twitter card type. Defaults to `summary_large_image` when an image exists. */
|
|
61
|
+
twitterCard?: TwitterCard;
|
|
62
|
+
/** The value for the `robots` meta tag. */
|
|
63
|
+
robots?: string;
|
|
64
|
+
/** Add `noindex` to the robots directives. */
|
|
65
|
+
noIndex?: boolean;
|
|
66
|
+
/** Add `nofollow` to the robots directives. */
|
|
67
|
+
noFollow?: boolean;
|
|
68
|
+
/** One JSON-LD document, or several documents to render. */
|
|
69
|
+
jsonLd?: JsonLdValue | readonly JsonLdValue[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Normalized SEO data shared by the framework adapters. */
|
|
73
|
+
export interface BuiltSeo {
|
|
74
|
+
title?: string;
|
|
75
|
+
description?: string;
|
|
76
|
+
url?: string;
|
|
77
|
+
canonical?: string;
|
|
78
|
+
image?: string;
|
|
79
|
+
imageAlt?: string;
|
|
80
|
+
siteName?: string;
|
|
81
|
+
type: OpenGraphType;
|
|
82
|
+
locale?: string;
|
|
83
|
+
twitterCard: TwitterCard;
|
|
84
|
+
robots?: string;
|
|
85
|
+
jsonLd: readonly JsonLdValue[];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function normalizeText(value: string | undefined): string | undefined {
|
|
89
|
+
if (value === undefined) return undefined;
|
|
90
|
+
|
|
91
|
+
const normalized = value.trim();
|
|
92
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function normalizeUrl(value: SeoUrl | undefined): string | undefined {
|
|
96
|
+
if (value === undefined) return undefined;
|
|
97
|
+
|
|
98
|
+
const normalized = typeof value === "string" ? value.trim() : value.href;
|
|
99
|
+
return normalized.length > 0 ? normalized : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function normalizeRobots(options: SeoOptions): string | undefined {
|
|
103
|
+
const directives = normalizeText(options.robots)
|
|
104
|
+
?.split(",")
|
|
105
|
+
.map((directive) => directive.trim())
|
|
106
|
+
.filter(Boolean) ?? [];
|
|
107
|
+
|
|
108
|
+
if (options.noIndex) {
|
|
109
|
+
for (let index = directives.length - 1; index >= 0; index -= 1) {
|
|
110
|
+
const directive = directives[index]?.toLowerCase();
|
|
111
|
+
if (directive === "index" || directive === "noindex") {
|
|
112
|
+
directives.splice(index, 1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
directives.push("noindex");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (options.noFollow) {
|
|
119
|
+
for (let index = directives.length - 1; index >= 0; index -= 1) {
|
|
120
|
+
const directive = directives[index]?.toLowerCase();
|
|
121
|
+
if (directive === "follow" || directive === "nofollow") {
|
|
122
|
+
directives.splice(index, 1);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
directives.push("nofollow");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return directives.length > 0 ? directives.join(", ") : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function normalizeJsonLd(
|
|
132
|
+
value: SeoOptions["jsonLd"],
|
|
133
|
+
): readonly JsonLdValue[] {
|
|
134
|
+
if (value === undefined) return [];
|
|
135
|
+
return Array.isArray(value) ? value : [value];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Normalize SEO options once before passing them to a framework adapter.
|
|
140
|
+
*
|
|
141
|
+
* This function has no framework or browser dependencies and is safe to call
|
|
142
|
+
* during server rendering.
|
|
143
|
+
*/
|
|
144
|
+
export function buildSeo(options: SeoOptions = {}): BuiltSeo {
|
|
145
|
+
const url = normalizeUrl(options.url);
|
|
146
|
+
const canonical = normalizeUrl(options.canonical ?? options.url);
|
|
147
|
+
const image = normalizeUrl(options.image);
|
|
148
|
+
|
|
149
|
+
return {
|
|
150
|
+
title: normalizeText(options.title),
|
|
151
|
+
description: normalizeText(options.description),
|
|
152
|
+
url,
|
|
153
|
+
canonical,
|
|
154
|
+
image,
|
|
155
|
+
imageAlt: normalizeText(options.imageAlt),
|
|
156
|
+
siteName: normalizeText(options.siteName),
|
|
157
|
+
type: options.type ?? "website",
|
|
158
|
+
locale: normalizeText(options.locale),
|
|
159
|
+
twitterCard: options.twitterCard ?? (image ? "summary_large_image" : "summary"),
|
|
160
|
+
robots: normalizeRobots(options),
|
|
161
|
+
jsonLd: normalizeJsonLd(options.jsonLd),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeJsonForHtml(value: string): string {
|
|
166
|
+
return value
|
|
167
|
+
.replace(/</g, "\\u003c")
|
|
168
|
+
.replace(/>/g, "\\u003e")
|
|
169
|
+
.replace(/&/g, "\\u0026")
|
|
170
|
+
.replace(/\u2028/g, "\\u2028")
|
|
171
|
+
.replace(/\u2029/g, "\\u2029");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Serialize JSON-LD for an inline HTML script safely.
|
|
176
|
+
*
|
|
177
|
+
* HTML-significant characters are escaped after JSON encoding, preventing a
|
|
178
|
+
* value such as `</script>` from closing the containing script element.
|
|
179
|
+
*/
|
|
180
|
+
export function serializeJsonLd(value: JsonLdValue): string {
|
|
181
|
+
try {
|
|
182
|
+
return escapeJsonForHtml(JSON.stringify(value) ?? "null");
|
|
183
|
+
} catch (error) {
|
|
184
|
+
throw new TypeError("JSON-LD data must be JSON serializable", { cause: error });
|
|
185
|
+
}
|
|
186
|
+
}
|