@alepha/react 0.14.0 → 0.14.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/auth/index.browser.js +1488 -4
- package/dist/auth/index.browser.js.map +1 -1
- package/dist/auth/index.d.ts +2 -2
- package/dist/auth/index.js +1827 -4
- package/dist/auth/index.js.map +1 -1
- package/dist/core/index.d.ts +54 -937
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +132 -2010
- package/dist/core/index.js.map +1 -1
- package/dist/form/index.d.ts.map +1 -1
- package/dist/form/index.js +6 -1
- package/dist/form/index.js.map +1 -1
- package/dist/head/index.browser.js +191 -17
- package/dist/head/index.browser.js.map +1 -1
- package/dist/head/index.d.ts +652 -31
- package/dist/head/index.d.ts.map +1 -1
- package/dist/head/index.js +209 -18
- package/dist/head/index.js.map +1 -1
- package/dist/{core → router}/index.browser.js +126 -516
- package/dist/router/index.browser.js.map +1 -0
- package/dist/router/index.d.ts +1334 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +1939 -0
- package/dist/router/index.js.map +1 -0
- package/package.json +12 -6
- package/src/auth/index.ts +1 -1
- package/src/auth/services/ReactAuth.ts +1 -1
- package/src/core/components/ClientOnly.tsx +14 -0
- package/src/core/components/ErrorBoundary.tsx +3 -2
- package/src/core/contexts/AlephaContext.ts +3 -0
- package/src/core/contexts/AlephaProvider.tsx +2 -1
- package/src/core/index.ts +13 -102
- package/src/form/services/FormModel.ts +5 -0
- package/src/head/helpers/SeoExpander.ts +141 -0
- package/src/head/index.browser.ts +1 -0
- package/src/head/index.ts +17 -7
- package/src/head/interfaces/Head.ts +69 -27
- package/src/head/providers/BrowserHeadProvider.ts +45 -12
- package/src/head/providers/HeadProvider.ts +32 -8
- package/src/head/providers/ServerHeadProvider.ts +34 -2
- package/src/{core → router}/components/ErrorViewer.tsx +2 -0
- package/src/router/components/Link.tsx +21 -0
- package/src/{core → router}/components/NestedView.tsx +3 -5
- package/src/router/components/NotFound.tsx +30 -0
- package/src/router/errors/Redirection.ts +28 -0
- package/src/{core → router}/hooks/useActive.ts +6 -2
- package/src/{core → router}/hooks/useQueryParams.ts +2 -2
- package/src/{core → router}/hooks/useRouter.ts +1 -1
- package/src/{core → router}/hooks/useRouterState.ts +1 -1
- package/src/{core → router}/index.browser.ts +14 -12
- package/src/{core/index.shared-router.ts → router/index.shared.ts} +6 -3
- package/src/router/index.ts +125 -0
- package/src/{core → router}/primitives/$page.ts +1 -1
- package/src/{core → router}/providers/ReactBrowserProvider.ts +3 -13
- package/src/{core → router}/providers/ReactBrowserRendererProvider.ts +3 -0
- package/src/{core → router}/providers/ReactBrowserRouterProvider.ts +3 -0
- package/src/{core → router}/providers/ReactPageProvider.ts +5 -3
- package/src/{core → router}/providers/ReactServerProvider.ts +9 -28
- package/src/{core → router}/services/ReactPageServerService.ts +3 -0
- package/src/{core → router}/services/ReactPageService.ts +5 -5
- package/src/{core → router}/services/ReactRouter.ts +26 -5
- package/dist/core/index.browser.js.map +0 -1
- package/dist/core/index.native.js +0 -403
- package/dist/core/index.native.js.map +0 -1
- package/src/core/components/Link.tsx +0 -18
- package/src/core/components/NotFound.tsx +0 -27
- package/src/core/errors/Redirection.ts +0 -13
- package/src/core/hooks/useSchema.ts +0 -88
- package/src/core/index.native.ts +0 -21
- package/src/core/index.shared.ts +0 -9
- /package/src/{core → router}/contexts/RouterLayerContext.ts +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { Head, HeadMeta } from "../interfaces/Head.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Expands Head configuration into SEO meta tags.
|
|
5
|
+
*
|
|
6
|
+
* Generates:
|
|
7
|
+
* - `<meta name="description">` from head.description
|
|
8
|
+
* - `<meta property="og:*">` OpenGraph tags
|
|
9
|
+
* - `<meta name="twitter:*">` Twitter Card tags
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const helper = new SeoExpander();
|
|
14
|
+
* const { meta, link } = helper.expand({
|
|
15
|
+
* title: "My App",
|
|
16
|
+
* description: "Build amazing apps",
|
|
17
|
+
* image: "https://example.com/og.png",
|
|
18
|
+
* url: "https://example.com/",
|
|
19
|
+
* });
|
|
20
|
+
* ```
|
|
21
|
+
*/
|
|
22
|
+
export class SeoExpander {
|
|
23
|
+
public expand(head: Head): {
|
|
24
|
+
meta: HeadMeta[];
|
|
25
|
+
link: Array<{ rel: string; href: string }>;
|
|
26
|
+
} {
|
|
27
|
+
const meta: HeadMeta[] = [];
|
|
28
|
+
const link: Array<{ rel: string; href: string }> = [];
|
|
29
|
+
|
|
30
|
+
// Only expand SEO if there's meaningful content beyond just title
|
|
31
|
+
const hasSeoContent =
|
|
32
|
+
head.description ||
|
|
33
|
+
head.image ||
|
|
34
|
+
head.url ||
|
|
35
|
+
head.siteName ||
|
|
36
|
+
head.locale ||
|
|
37
|
+
head.type ||
|
|
38
|
+
head.og ||
|
|
39
|
+
head.twitter;
|
|
40
|
+
|
|
41
|
+
if (!hasSeoContent) {
|
|
42
|
+
return { meta, link };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Base description
|
|
46
|
+
if (head.description) {
|
|
47
|
+
meta.push({ name: "description", content: head.description });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Canonical URL
|
|
51
|
+
if (head.url) {
|
|
52
|
+
link.push({ rel: "canonical", href: head.url });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// OpenGraph tags
|
|
56
|
+
this.expandOpenGraph(head, meta);
|
|
57
|
+
|
|
58
|
+
// Twitter Card tags
|
|
59
|
+
this.expandTwitter(head, meta);
|
|
60
|
+
|
|
61
|
+
return { meta, link };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
protected expandOpenGraph(head: Head, meta: HeadMeta[]): void {
|
|
65
|
+
const ogTitle = head.og?.title ?? head.title;
|
|
66
|
+
const ogDescription = head.og?.description ?? head.description;
|
|
67
|
+
const ogImage = head.og?.image ?? head.image;
|
|
68
|
+
|
|
69
|
+
if (head.type || ogTitle) {
|
|
70
|
+
meta.push({ property: "og:type", content: head.type ?? "website" });
|
|
71
|
+
}
|
|
72
|
+
if (head.url) {
|
|
73
|
+
meta.push({ property: "og:url", content: head.url });
|
|
74
|
+
}
|
|
75
|
+
if (ogTitle) {
|
|
76
|
+
meta.push({ property: "og:title", content: ogTitle });
|
|
77
|
+
}
|
|
78
|
+
if (ogDescription) {
|
|
79
|
+
meta.push({ property: "og:description", content: ogDescription });
|
|
80
|
+
}
|
|
81
|
+
if (ogImage) {
|
|
82
|
+
meta.push({ property: "og:image", content: ogImage });
|
|
83
|
+
if (head.imageWidth) {
|
|
84
|
+
meta.push({
|
|
85
|
+
property: "og:image:width",
|
|
86
|
+
content: String(head.imageWidth),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
if (head.imageHeight) {
|
|
90
|
+
meta.push({
|
|
91
|
+
property: "og:image:height",
|
|
92
|
+
content: String(head.imageHeight),
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (head.imageAlt) {
|
|
96
|
+
meta.push({ property: "og:image:alt", content: head.imageAlt });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (head.siteName) {
|
|
100
|
+
meta.push({ property: "og:site_name", content: head.siteName });
|
|
101
|
+
}
|
|
102
|
+
if (head.locale) {
|
|
103
|
+
meta.push({ property: "og:locale", content: head.locale });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
protected expandTwitter(head: Head, meta: HeadMeta[]): void {
|
|
108
|
+
const twitterTitle = head.twitter?.title ?? head.title;
|
|
109
|
+
const twitterDescription = head.twitter?.description ?? head.description;
|
|
110
|
+
const twitterImage = head.twitter?.image ?? head.image;
|
|
111
|
+
|
|
112
|
+
if (head.twitter?.card || twitterTitle || twitterImage) {
|
|
113
|
+
meta.push({
|
|
114
|
+
name: "twitter:card",
|
|
115
|
+
content:
|
|
116
|
+
head.twitter?.card ?? (twitterImage ? "summary_large_image" : "summary"),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
if (head.url) {
|
|
120
|
+
meta.push({ name: "twitter:url", content: head.url });
|
|
121
|
+
}
|
|
122
|
+
if (twitterTitle) {
|
|
123
|
+
meta.push({ name: "twitter:title", content: twitterTitle });
|
|
124
|
+
}
|
|
125
|
+
if (twitterDescription) {
|
|
126
|
+
meta.push({ name: "twitter:description", content: twitterDescription });
|
|
127
|
+
}
|
|
128
|
+
if (twitterImage) {
|
|
129
|
+
meta.push({ name: "twitter:image", content: twitterImage });
|
|
130
|
+
if (head.imageAlt) {
|
|
131
|
+
meta.push({ name: "twitter:image:alt", content: head.imageAlt });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (head.twitter?.site) {
|
|
135
|
+
meta.push({ name: "twitter:site", content: head.twitter.site });
|
|
136
|
+
}
|
|
137
|
+
if (head.twitter?.creator) {
|
|
138
|
+
meta.push({ name: "twitter:creator", content: head.twitter.creator });
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -8,6 +8,7 @@ import { BrowserHeadProvider } from "./providers/BrowserHeadProvider.ts";
|
|
|
8
8
|
export * from "./primitives/$head.ts";
|
|
9
9
|
export * from "./hooks/useHead.ts";
|
|
10
10
|
export * from "./interfaces/Head.ts";
|
|
11
|
+
export * from "./helpers/SeoExpander.ts";
|
|
11
12
|
export * from "./providers/BrowserHeadProvider.ts";
|
|
12
13
|
|
|
13
14
|
// ---------------------------------------------------------------------------------------------------------------------
|
package/src/head/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "@alepha/react";
|
|
1
|
+
import { AlephaReact } from "@alepha/react";
|
|
2
|
+
import type {
|
|
3
|
+
PageConfigSchema,
|
|
4
|
+
TPropsDefault,
|
|
5
|
+
TPropsParentDefault,
|
|
6
|
+
} from "@alepha/react/router";
|
|
7
7
|
import { $module } from "alepha";
|
|
8
8
|
import { $head } from "./primitives/$head.ts";
|
|
9
9
|
import type { Head } from "./interfaces/Head.ts";
|
|
@@ -15,11 +15,13 @@ import { HeadProvider } from "./providers/HeadProvider.ts";
|
|
|
15
15
|
export * from "./primitives/$head.ts";
|
|
16
16
|
export * from "./hooks/useHead.ts";
|
|
17
17
|
export * from "./interfaces/Head.ts";
|
|
18
|
+
export * from "./helpers/SeoExpander.ts";
|
|
18
19
|
export * from "./providers/ServerHeadProvider.ts";
|
|
19
20
|
|
|
20
21
|
// ---------------------------------------------------------------------------------------------------------------------
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
// Augment PagePrimitiveOptions in router module
|
|
24
|
+
declare module "@alepha/react/router" {
|
|
23
25
|
interface PagePrimitiveOptions<
|
|
24
26
|
TConfig extends PageConfigSchema = PageConfigSchema,
|
|
25
27
|
TProps extends object = TPropsDefault,
|
|
@@ -27,7 +29,10 @@ declare module "@alepha/react" {
|
|
|
27
29
|
> {
|
|
28
30
|
head?: Head | ((props: TProps, previous?: Head) => Head);
|
|
29
31
|
}
|
|
32
|
+
}
|
|
30
33
|
|
|
34
|
+
// Augment ReactRouterState in router module
|
|
35
|
+
declare module "@alepha/react/router" {
|
|
31
36
|
interface ReactRouterState {
|
|
32
37
|
head: Head;
|
|
33
38
|
}
|
|
@@ -38,6 +43,11 @@ declare module "@alepha/react" {
|
|
|
38
43
|
/**
|
|
39
44
|
* Fill `<head>` server & client side.
|
|
40
45
|
*
|
|
46
|
+
* Generate SEO-friendly meta tags and titles for your React application using AlephaReactHead module.
|
|
47
|
+
*
|
|
48
|
+
* This module provides services and primitives to manage the document head both on the server and client side,
|
|
49
|
+
* ensuring that your application is optimized for search engines and social media sharing.
|
|
50
|
+
*
|
|
41
51
|
* @see {@link ServerHeadProvider}
|
|
42
52
|
* @module alepha.react.head
|
|
43
53
|
*/
|
|
@@ -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,19 @@ 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>;
|
|
76
|
+
/** Link tags (e.g., stylesheets, preload, canonical) */
|
|
46
77
|
link?: Array<{ rel: string; href: string }>;
|
|
78
|
+
/** Script tags - any valid script attributes (src, type, async, defer, etc.) */
|
|
79
|
+
script?: Array<Record<string, string | boolean>>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface HeadMeta {
|
|
83
|
+
/** Meta name attribute (e.g., "description", "twitter:card") */
|
|
84
|
+
name?: string;
|
|
85
|
+
/** Meta property attribute (e.g., "og:title", "og:image") */
|
|
86
|
+
property?: string;
|
|
87
|
+
/** Meta content value */
|
|
88
|
+
content: string;
|
|
47
89
|
}
|
|
@@ -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
|
-
import type { PageRoute, ReactRouterState } from "@alepha/react";
|
|
1
|
+
import type { PageRoute, ReactRouterState } from "@alepha/react/router";
|
|
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,18 @@ 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
|
+
script: [...(state.head.script ?? []), ...(head.script ?? [])],
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
protected fillHeadByPage(
|
|
30
41
|
page: PageRoute,
|
|
31
42
|
state: ReactRouterState,
|
|
@@ -42,6 +53,11 @@ export class HeadProvider {
|
|
|
42
53
|
? page.head(props, state.head)
|
|
43
54
|
: page.head;
|
|
44
55
|
|
|
56
|
+
// Expand SEO fields into meta tags
|
|
57
|
+
const { meta, link } = this.seoExpander.expand(head);
|
|
58
|
+
state.head.meta = [...(state.head.meta ?? []), ...meta];
|
|
59
|
+
state.head.link = [...(state.head.link ?? []), ...link];
|
|
60
|
+
|
|
45
61
|
if (head.title) {
|
|
46
62
|
state.head ??= {};
|
|
47
63
|
|
|
@@ -71,5 +87,13 @@ export class HeadProvider {
|
|
|
71
87
|
if (head.meta) {
|
|
72
88
|
state.head.meta = [...(state.head.meta ?? []), ...(head.meta ?? [])];
|
|
73
89
|
}
|
|
90
|
+
|
|
91
|
+
if (head.link) {
|
|
92
|
+
state.head.link = [...(state.head.link ?? []), ...(head.link ?? [])];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (head.script) {
|
|
96
|
+
state.head.script = [...(state.head.script ?? []), ...(head.script ?? [])];
|
|
97
|
+
}
|
|
74
98
|
}
|
|
75
99
|
}
|
|
@@ -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
|
|
|
@@ -68,6 +68,12 @@ export class ServerHeadProvider {
|
|
|
68
68
|
}
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
if (head.script) {
|
|
72
|
+
for (const script of head.script) {
|
|
73
|
+
headContent += this.renderScriptTag(script);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
71
77
|
// Inject into <head>...</head>
|
|
72
78
|
result = result.replace(
|
|
73
79
|
/<head([^>]*)>(.*?)<\/head>/is,
|
|
@@ -112,4 +118,30 @@ export class ServerHeadProvider {
|
|
|
112
118
|
.replace(/"/g, """)
|
|
113
119
|
.replace(/'/g, "'");
|
|
114
120
|
}
|
|
121
|
+
|
|
122
|
+
protected renderMetaTag(meta: HeadMeta): string {
|
|
123
|
+
// OpenGraph tags use property attribute
|
|
124
|
+
if (meta.property) {
|
|
125
|
+
return `<meta property="${this.escapeHtml(meta.property)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
126
|
+
}
|
|
127
|
+
// Standard meta tags use name attribute
|
|
128
|
+
if (meta.name) {
|
|
129
|
+
return `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
130
|
+
}
|
|
131
|
+
return "";
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
protected renderScriptTag(script: Record<string, string | boolean>): string {
|
|
135
|
+
const attrs = Object.entries(script)
|
|
136
|
+
.filter(([, value]) => value !== false)
|
|
137
|
+
.map(([key, value]) => {
|
|
138
|
+
// Boolean attributes - render without value if true
|
|
139
|
+
if (value === true) {
|
|
140
|
+
return key;
|
|
141
|
+
}
|
|
142
|
+
return `${key}="${this.escapeHtml(String(value))}"`;
|
|
143
|
+
})
|
|
144
|
+
.join(" ");
|
|
145
|
+
return `<script ${attrs}></script>\n`;
|
|
146
|
+
}
|
|
115
147
|
}
|
|
@@ -47,6 +47,8 @@ const ErrorViewer = ({ error, alepha }: ErrorViewerProps) => {
|
|
|
47
47
|
|
|
48
48
|
export default ErrorViewer;
|
|
49
49
|
|
|
50
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
51
|
+
|
|
50
52
|
/**
|
|
51
53
|
* Parse stack trace string into structured frames
|
|
52
54
|
*/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type AnchorHTMLAttributes, createElement } from "react";
|
|
2
|
+
import { useRouter } from "../hooks/useRouter.ts";
|
|
3
|
+
|
|
4
|
+
export interface LinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
|
5
|
+
href: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Link component for client-side navigation.
|
|
10
|
+
*
|
|
11
|
+
* It's a simple wrapper around an anchor (`<a>`) element using the `useRouter` hook.
|
|
12
|
+
*/
|
|
13
|
+
const Link = (props: LinkProps) => {
|
|
14
|
+
const router = useRouter();
|
|
15
|
+
|
|
16
|
+
return createElement(
|
|
17
|
+
"a", { ...props, ...router.anchor(props.href) }, props.children
|
|
18
|
+
)
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export default Link;
|
|
@@ -1,13 +1,11 @@
|
|
|
1
1
|
import { memo, type ReactNode, use, useRef, useState } from "react";
|
|
2
|
+
import type { ReactRouterState } from "../providers/ReactPageProvider.ts";
|
|
2
3
|
import { RouterLayerContext } from "../contexts/RouterLayerContext.ts";
|
|
3
|
-
import type { ErrorHandler, PageAnimation } from "../primitives/$page.ts";
|
|
4
4
|
import { Redirection } from "../errors/Redirection.ts";
|
|
5
|
-
import { useEvents } from "../hooks/useEvents.ts";
|
|
6
5
|
import { useRouterState } from "../hooks/useRouterState.ts";
|
|
7
|
-
import type {
|
|
8
|
-
import ErrorBoundary from "./ErrorBoundary.tsx";
|
|
6
|
+
import type { PageAnimation } from "../primitives/$page.ts";
|
|
9
7
|
import ErrorViewer from "./ErrorViewer.tsx";
|
|
10
|
-
import { useAlepha } from "
|
|
8
|
+
import { ErrorBoundary, useAlepha, useEvents } from "@alepha/react";
|
|
11
9
|
|
|
12
10
|
export interface NestedViewProps {
|
|
13
11
|
children?: ReactNode;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default 404 Not Found page component.
|
|
5
|
+
*/
|
|
6
|
+
const NotFound = (props: { style?: CSSProperties }) => (
|
|
7
|
+
<div
|
|
8
|
+
style={{
|
|
9
|
+
width: "100%",
|
|
10
|
+
minHeight: "90vh",
|
|
11
|
+
boxSizing: "border-box",
|
|
12
|
+
display: "flex",
|
|
13
|
+
flexDirection: "column",
|
|
14
|
+
justifyContent: "center",
|
|
15
|
+
alignItems: "center",
|
|
16
|
+
textAlign: "center",
|
|
17
|
+
fontFamily:
|
|
18
|
+
'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
|
|
19
|
+
padding: "2rem",
|
|
20
|
+
...props.style,
|
|
21
|
+
}}
|
|
22
|
+
>
|
|
23
|
+
<div style={{ fontSize: "6rem", fontWeight: 200, lineHeight: 1 }}>404</div>
|
|
24
|
+
<div style={{ fontSize: "0.875rem", marginTop: "1rem", opacity: 0.6 }}>
|
|
25
|
+
Page not found
|
|
26
|
+
</div>
|
|
27
|
+
</div>
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
export default NotFound;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { AlephaError } from "alepha";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Used for Redirection during the page loading.
|
|
5
|
+
*
|
|
6
|
+
* Depends on the context, it can be thrown or just returned.
|
|
7
|
+
*
|
|
8
|
+
* @example
|
|
9
|
+
* ```ts
|
|
10
|
+
* import { Redirection } from "@alepha/react";
|
|
11
|
+
*
|
|
12
|
+
* const MyPage = $page({
|
|
13
|
+
* resolve: async () => {
|
|
14
|
+
* if (needRedirect) {
|
|
15
|
+
* throw new Redirection("/new-path");
|
|
16
|
+
* }
|
|
17
|
+
* },
|
|
18
|
+
* });
|
|
19
|
+
* ```
|
|
20
|
+
*/
|
|
21
|
+
export class Redirection extends AlephaError {
|
|
22
|
+
public readonly redirect: string;
|
|
23
|
+
|
|
24
|
+
constructor(redirect: string) {
|
|
25
|
+
super("Redirection");
|
|
26
|
+
this.redirect = redirect;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -8,11 +8,15 @@ export interface UseActiveOptions {
|
|
|
8
8
|
startWith?: boolean;
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Hook to determine if a given route is active and to provide anchor props for navigation.
|
|
13
|
+
* This hook refreshes on router state changes.
|
|
14
|
+
*/
|
|
11
15
|
export const useActive = (args: string | UseActiveOptions): UseActiveHook => {
|
|
16
|
+
useRouterState();
|
|
17
|
+
|
|
12
18
|
const router = useRouter();
|
|
13
19
|
const [isPending, setPending] = useState(false);
|
|
14
|
-
const state = useRouterState();
|
|
15
|
-
const current = state.url.pathname;
|
|
16
20
|
|
|
17
21
|
const options: UseActiveOptions =
|
|
18
22
|
typeof args === "string" ? { href: args } : { ...args, href: args.href };
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { Alepha, Static, TObject } from "alepha";
|
|
2
2
|
import { useEffect, useState } from "react";
|
|
3
|
-
import { useAlepha } from "
|
|
3
|
+
import { useAlepha } from "@alepha/react";
|
|
4
4
|
import { useRouter } from "./useRouter.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
7
|
+
* Hook to manage query parameters in the URL using a defined schema.
|
|
8
8
|
*/
|
|
9
9
|
export const useQueryParams = <T extends TObject>(
|
|
10
10
|
schema: T,
|