@alepha/react 0.14.0 → 0.14.1
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/README.md +1 -1
- package/dist/head/index.browser.js +189 -17
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +101 -24
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +195 -18
- package/dist/head/index.js.map +1 -1
- package/package.json +3 -3
- package/src/head/helpers/SeoExpander.ts +141 -0
- package/src/head/index.browser.ts +1 -0
- package/src/head/index.ts +1 -0
- package/src/head/interfaces/Head.ts +66 -27
- package/src/head/providers/BrowserHeadProvider.ts +45 -12
- package/src/head/providers/HeadProvider.ts +26 -7
- package/src/head/providers/ServerHeadProvider.ts +14 -2
|
@@ -1,39 +1,68 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Complete head configuration combining basic head elements with SEO fields.
|
|
3
|
+
*
|
|
4
|
+
* @example
|
|
5
|
+
* ```ts
|
|
6
|
+
* $head({
|
|
7
|
+
* title: "My App",
|
|
8
|
+
* description: "Build amazing apps",
|
|
9
|
+
* image: "https://example.com/og.png",
|
|
10
|
+
* url: "https://example.com/",
|
|
11
|
+
* siteName: "My App",
|
|
12
|
+
* twitter: { card: "summary_large_image" },
|
|
13
|
+
* })
|
|
14
|
+
* ```
|
|
15
|
+
*/
|
|
16
|
+
export interface Head extends SimpleHead, Seo {}
|
|
3
17
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
18
|
+
/**
|
|
19
|
+
* SEO configuration for automatic meta tag generation.
|
|
20
|
+
* Fields are used for meta description, OpenGraph, and Twitter Card tags.
|
|
21
|
+
*/
|
|
22
|
+
export interface Seo {
|
|
23
|
+
/** Page description - used for meta description, og:description, twitter:description */
|
|
24
|
+
description?: string;
|
|
25
|
+
/** Primary image URL - used for og:image and twitter:image */
|
|
26
|
+
image?: string;
|
|
27
|
+
/** Canonical URL - used for og:url and link rel="canonical" */
|
|
28
|
+
url?: string;
|
|
29
|
+
/** Site name - used for og:site_name */
|
|
30
|
+
siteName?: string;
|
|
31
|
+
/** Locale - used for og:locale (e.g., "en_US") */
|
|
32
|
+
locale?: string;
|
|
33
|
+
/** Content type - used for og:type (default: "website") */
|
|
34
|
+
type?: "website" | "article" | "product" | "profile" | string;
|
|
35
|
+
/** Image width in pixels - used for og:image:width */
|
|
36
|
+
imageWidth?: number;
|
|
37
|
+
/** Image height in pixels - used for og:image:height */
|
|
38
|
+
imageHeight?: number;
|
|
39
|
+
/** Image alt text - used for og:image:alt and twitter:image:alt */
|
|
40
|
+
imageAlt?: string;
|
|
22
41
|
|
|
23
|
-
|
|
42
|
+
/** Twitter-specific overrides */
|
|
43
|
+
twitter?: {
|
|
44
|
+
/** Twitter card type */
|
|
45
|
+
card?: "summary" | "summary_large_image" | "app" | "player";
|
|
46
|
+
/** @username of website */
|
|
47
|
+
site?: string;
|
|
48
|
+
/** @username of content creator */
|
|
49
|
+
creator?: string;
|
|
50
|
+
/** Override title for Twitter */
|
|
24
51
|
title?: string;
|
|
52
|
+
/** Override description for Twitter */
|
|
25
53
|
description?: string;
|
|
54
|
+
/** Override image for Twitter */
|
|
26
55
|
image?: string;
|
|
27
|
-
url?: string;
|
|
28
|
-
type?: string;
|
|
29
56
|
};
|
|
30
57
|
|
|
31
|
-
|
|
32
|
-
|
|
58
|
+
/** OpenGraph-specific overrides */
|
|
59
|
+
og?: {
|
|
60
|
+
/** Override title for OpenGraph */
|
|
33
61
|
title?: string;
|
|
62
|
+
/** Override description for OpenGraph */
|
|
34
63
|
description?: string;
|
|
64
|
+
/** Override image for OpenGraph */
|
|
35
65
|
image?: string;
|
|
36
|
-
site?: string;
|
|
37
66
|
};
|
|
38
67
|
}
|
|
39
68
|
|
|
@@ -42,6 +71,16 @@ export interface SimpleHead {
|
|
|
42
71
|
titleSeparator?: string;
|
|
43
72
|
htmlAttributes?: Record<string, string>;
|
|
44
73
|
bodyAttributes?: Record<string, string>;
|
|
45
|
-
|
|
74
|
+
/** Meta tags - supports both name and property attributes */
|
|
75
|
+
meta?: Array<HeadMeta>;
|
|
46
76
|
link?: Array<{ rel: string; href: string }>;
|
|
47
77
|
}
|
|
78
|
+
|
|
79
|
+
export interface HeadMeta {
|
|
80
|
+
/** Meta name attribute (e.g., "description", "twitter:card") */
|
|
81
|
+
name?: string;
|
|
82
|
+
/** Meta property attribute (e.g., "og:title", "og:image") */
|
|
83
|
+
property?: string;
|
|
84
|
+
/** Meta content value */
|
|
85
|
+
content: string;
|
|
86
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { $hook, $inject } from "alepha";
|
|
2
|
-
import type { Head } from "../interfaces/Head.ts";
|
|
2
|
+
import type { Head, HeadMeta } from "../interfaces/Head.ts";
|
|
3
3
|
import { HeadProvider } from "./HeadProvider.ts";
|
|
4
4
|
|
|
5
5
|
export class BrowserHeadProvider {
|
|
@@ -49,7 +49,8 @@ export class BrowserHeadProvider {
|
|
|
49
49
|
return attrs;
|
|
50
50
|
},
|
|
51
51
|
get meta() {
|
|
52
|
-
const metas:
|
|
52
|
+
const metas: HeadMeta[] = [];
|
|
53
|
+
// Get meta tags with name attribute
|
|
53
54
|
for (const meta of document.head.querySelectorAll("meta[name]")) {
|
|
54
55
|
const name = meta.getAttribute("name");
|
|
55
56
|
const content = meta.getAttribute("content");
|
|
@@ -57,6 +58,14 @@ export class BrowserHeadProvider {
|
|
|
57
58
|
metas.push({ name, content });
|
|
58
59
|
}
|
|
59
60
|
}
|
|
61
|
+
// Get meta tags with property attribute (OpenGraph)
|
|
62
|
+
for (const meta of document.head.querySelectorAll("meta[property]")) {
|
|
63
|
+
const property = meta.getAttribute("property");
|
|
64
|
+
const content = meta.getAttribute("content");
|
|
65
|
+
if (property && content) {
|
|
66
|
+
metas.push({ property, content });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
60
69
|
return metas;
|
|
61
70
|
},
|
|
62
71
|
};
|
|
@@ -89,16 +98,7 @@ export class BrowserHeadProvider {
|
|
|
89
98
|
|
|
90
99
|
if (head.meta) {
|
|
91
100
|
for (const it of head.meta) {
|
|
92
|
-
|
|
93
|
-
const meta = document.querySelector(`meta[name="${name}"]`);
|
|
94
|
-
if (meta) {
|
|
95
|
-
meta.setAttribute("content", content);
|
|
96
|
-
} else {
|
|
97
|
-
const newMeta = document.createElement("meta");
|
|
98
|
-
newMeta.setAttribute("name", name);
|
|
99
|
-
newMeta.setAttribute("content", content);
|
|
100
|
-
document.head.appendChild(newMeta);
|
|
101
|
-
}
|
|
101
|
+
this.renderMetaTag(document, it);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
@@ -115,4 +115,37 @@ export class BrowserHeadProvider {
|
|
|
115
115
|
}
|
|
116
116
|
}
|
|
117
117
|
}
|
|
118
|
+
|
|
119
|
+
protected renderMetaTag(document: Document, meta: HeadMeta): void {
|
|
120
|
+
const { content } = meta;
|
|
121
|
+
|
|
122
|
+
// Handle OpenGraph tags (property attribute)
|
|
123
|
+
if (meta.property) {
|
|
124
|
+
const existing = document.querySelector(
|
|
125
|
+
`meta[property="${meta.property}"]`,
|
|
126
|
+
);
|
|
127
|
+
if (existing) {
|
|
128
|
+
existing.setAttribute("content", content);
|
|
129
|
+
} else {
|
|
130
|
+
const newMeta = document.createElement("meta");
|
|
131
|
+
newMeta.setAttribute("property", meta.property);
|
|
132
|
+
newMeta.setAttribute("content", content);
|
|
133
|
+
document.head.appendChild(newMeta);
|
|
134
|
+
}
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Handle standard meta tags (name attribute)
|
|
139
|
+
if (meta.name) {
|
|
140
|
+
const existing = document.querySelector(`meta[name="${meta.name}"]`);
|
|
141
|
+
if (existing) {
|
|
142
|
+
existing.setAttribute("content", content);
|
|
143
|
+
} else {
|
|
144
|
+
const newMeta = document.createElement("meta");
|
|
145
|
+
newMeta.setAttribute("name", meta.name);
|
|
146
|
+
newMeta.setAttribute("content", content);
|
|
147
|
+
document.head.appendChild(newMeta);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
118
151
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import type { PageRoute, ReactRouterState } from "@alepha/react";
|
|
2
|
+
import { $inject } from "alepha";
|
|
3
|
+
import { SeoExpander } from "../helpers/SeoExpander.ts";
|
|
2
4
|
import type { Head } from "../interfaces/Head.ts";
|
|
3
5
|
|
|
4
6
|
export class HeadProvider {
|
|
7
|
+
protected readonly seoExpander = $inject(SeoExpander);
|
|
8
|
+
|
|
5
9
|
public global?: Array<Head | (() => Head)> = [];
|
|
6
10
|
|
|
7
11
|
public fillHead(state: ReactRouterState) {
|
|
@@ -10,13 +14,8 @@ export class HeadProvider {
|
|
|
10
14
|
};
|
|
11
15
|
|
|
12
16
|
for (const h of this.global ?? []) {
|
|
13
|
-
const head =
|
|
14
|
-
|
|
15
|
-
state.head = {
|
|
16
|
-
...state.head,
|
|
17
|
-
...head,
|
|
18
|
-
meta: [...(state.head.meta ?? []), ...(head.meta ?? [])],
|
|
19
|
-
};
|
|
17
|
+
const head = typeof h === "function" ? h() : h;
|
|
18
|
+
this.mergeHead(state, head);
|
|
20
19
|
}
|
|
21
20
|
|
|
22
21
|
for (const layer of state.layers) {
|
|
@@ -26,6 +25,17 @@ export class HeadProvider {
|
|
|
26
25
|
}
|
|
27
26
|
}
|
|
28
27
|
|
|
28
|
+
protected mergeHead(state: ReactRouterState, head: Head): void {
|
|
29
|
+
// Expand SEO fields into meta tags
|
|
30
|
+
const { meta, link } = this.seoExpander.expand(head);
|
|
31
|
+
state.head = {
|
|
32
|
+
...state.head,
|
|
33
|
+
...head,
|
|
34
|
+
meta: [...(state.head.meta ?? []), ...meta, ...(head.meta ?? [])],
|
|
35
|
+
link: [...(state.head.link ?? []), ...link, ...(head.link ?? [])],
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
29
39
|
protected fillHeadByPage(
|
|
30
40
|
page: PageRoute,
|
|
31
41
|
state: ReactRouterState,
|
|
@@ -42,6 +52,11 @@ export class HeadProvider {
|
|
|
42
52
|
? page.head(props, state.head)
|
|
43
53
|
: page.head;
|
|
44
54
|
|
|
55
|
+
// Expand SEO fields into meta tags
|
|
56
|
+
const { meta, link } = this.seoExpander.expand(head);
|
|
57
|
+
state.head.meta = [...(state.head.meta ?? []), ...meta];
|
|
58
|
+
state.head.link = [...(state.head.link ?? []), ...link];
|
|
59
|
+
|
|
45
60
|
if (head.title) {
|
|
46
61
|
state.head ??= {};
|
|
47
62
|
|
|
@@ -71,5 +86,9 @@ export class HeadProvider {
|
|
|
71
86
|
if (head.meta) {
|
|
72
87
|
state.head.meta = [...(state.head.meta ?? []), ...(head.meta ?? [])];
|
|
73
88
|
}
|
|
89
|
+
|
|
90
|
+
if (head.link) {
|
|
91
|
+
state.head.link = [...(state.head.link ?? []), ...(head.link ?? [])];
|
|
92
|
+
}
|
|
74
93
|
}
|
|
75
94
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { $hook, $inject } from "alepha";
|
|
2
2
|
import { ServerTimingProvider } from "alepha/server";
|
|
3
|
-
import type { SimpleHead } from "../interfaces/Head.ts";
|
|
3
|
+
import type { HeadMeta, SimpleHead } from "../interfaces/Head.ts";
|
|
4
4
|
import { HeadProvider } from "./HeadProvider.ts";
|
|
5
5
|
|
|
6
6
|
export class ServerHeadProvider {
|
|
@@ -58,7 +58,7 @@ export class ServerHeadProvider {
|
|
|
58
58
|
|
|
59
59
|
if (head.meta) {
|
|
60
60
|
for (const meta of head.meta) {
|
|
61
|
-
headContent +=
|
|
61
|
+
headContent += this.renderMetaTag(meta);
|
|
62
62
|
}
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -112,4 +112,16 @@ export class ServerHeadProvider {
|
|
|
112
112
|
.replace(/"/g, """)
|
|
113
113
|
.replace(/'/g, "'");
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
protected renderMetaTag(meta: HeadMeta): string {
|
|
117
|
+
// OpenGraph tags use property attribute
|
|
118
|
+
if (meta.property) {
|
|
119
|
+
return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
120
|
+
}
|
|
121
|
+
// Standard meta tags use name attribute
|
|
122
|
+
if (meta.name) {
|
|
123
|
+
return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
124
|
+
}
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
115
127
|
}
|