@alepha/react 0.6.3 → 0.6.4
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/dist/index.browser.cjs +2 -1
- package/dist/index.browser.cjs.map +1 -0
- package/dist/index.browser.js +3 -2
- package/dist/index.browser.js.map +1 -0
- package/dist/index.cjs +3 -2
- package/dist/index.cjs.map +1 -0
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -0
- package/dist/{useActive-BVqdq757.cjs → useActive-BGtt_RNQ.cjs} +7 -6
- package/dist/useActive-BGtt_RNQ.cjs.map +1 -0
- package/dist/{useActive-dAmCT31a.js → useActive-QkvcaSmu.js} +7 -6
- package/dist/useActive-QkvcaSmu.js.map +1 -0
- package/package.json +10 -6
- package/src/components/Link.tsx +35 -0
- package/src/components/NestedView.tsx +36 -0
- package/src/contexts/RouterContext.ts +18 -0
- package/src/contexts/RouterLayerContext.ts +10 -0
- package/src/descriptors/$page.ts +143 -0
- package/src/errors/RedirectionError.ts +7 -0
- package/src/hooks/RouterHookApi.ts +156 -0
- package/src/hooks/useActive.ts +57 -0
- package/src/hooks/useClient.ts +6 -0
- package/src/hooks/useInject.ts +14 -0
- package/src/hooks/useQueryParams.ts +59 -0
- package/src/hooks/useRouter.ts +25 -0
- package/src/hooks/useRouterEvents.ts +43 -0
- package/src/hooks/useRouterState.ts +23 -0
- package/src/index.browser.ts +21 -0
- package/src/index.shared.ts +15 -0
- package/src/index.ts +48 -0
- package/src/providers/BrowserHeadProvider.ts +43 -0
- package/src/providers/BrowserRouterProvider.ts +146 -0
- package/src/providers/PageDescriptorProvider.ts +534 -0
- package/src/providers/ReactBrowserProvider.ts +223 -0
- package/src/providers/ReactServerProvider.ts +278 -0
- package/src/providers/ServerHeadProvider.ts +91 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { $hook, $inject, $logger, Alepha, type Static, t } from "@alepha/core";
|
|
2
|
+
import { HttpClient, type HttpClientLink } from "@alepha/server";
|
|
3
|
+
import type { Root } from "react-dom/client";
|
|
4
|
+
import { createRoot, hydrateRoot } from "react-dom/client";
|
|
5
|
+
import type { Head } from "../descriptors/$page.ts";
|
|
6
|
+
import { BrowserHeadProvider } from "./BrowserHeadProvider.ts";
|
|
7
|
+
import { BrowserRouterProvider } from "./BrowserRouterProvider.ts";
|
|
8
|
+
import type {
|
|
9
|
+
PreviousLayerData,
|
|
10
|
+
RouterState,
|
|
11
|
+
TransitionOptions,
|
|
12
|
+
} from "./PageDescriptorProvider.ts";
|
|
13
|
+
|
|
14
|
+
const envSchema = t.object({
|
|
15
|
+
REACT_ROOT_ID: t.string({ default: "root" }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
declare module "@alepha/core" {
|
|
19
|
+
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class ReactBrowserProvider {
|
|
23
|
+
protected readonly log = $logger();
|
|
24
|
+
protected readonly client = $inject(HttpClient);
|
|
25
|
+
protected readonly alepha = $inject(Alepha);
|
|
26
|
+
protected readonly router = $inject(BrowserRouterProvider);
|
|
27
|
+
protected readonly headProvider = $inject(BrowserHeadProvider);
|
|
28
|
+
protected readonly env = $inject(envSchema);
|
|
29
|
+
protected root!: Root;
|
|
30
|
+
|
|
31
|
+
public transitioning?: {
|
|
32
|
+
to: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
public state: RouterState = {
|
|
36
|
+
layers: [],
|
|
37
|
+
pathname: "",
|
|
38
|
+
search: "",
|
|
39
|
+
head: {},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
public get document() {
|
|
43
|
+
return window.document;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public get history() {
|
|
47
|
+
return window.history;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public get url(): string {
|
|
51
|
+
return window.location.pathname + window.location.search;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
public async invalidate(props?: Record<string, any>) {
|
|
55
|
+
const previous: PreviousLayerData[] = [];
|
|
56
|
+
|
|
57
|
+
if (props) {
|
|
58
|
+
const [key] = Object.keys(props);
|
|
59
|
+
const value = props[key];
|
|
60
|
+
|
|
61
|
+
for (const layer of this.state.layers) {
|
|
62
|
+
if (layer.props?.[key]) {
|
|
63
|
+
previous.push({
|
|
64
|
+
...layer,
|
|
65
|
+
props: {
|
|
66
|
+
...layer.props,
|
|
67
|
+
[key]: value,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
previous.push(layer);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
await this.render({ previous });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
*
|
|
81
|
+
* @param url
|
|
82
|
+
* @param options
|
|
83
|
+
*/
|
|
84
|
+
public async go(url: string, options: RouterGoOptions = {}): Promise<void> {
|
|
85
|
+
const result = await this.render({
|
|
86
|
+
url,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
if (result.url !== url) {
|
|
90
|
+
this.history.replaceState({}, "", result.url);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (options.replace) {
|
|
95
|
+
this.history.replaceState({}, "", url);
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this.history.pushState({}, "", url);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
protected async render(
|
|
103
|
+
options: {
|
|
104
|
+
url?: string;
|
|
105
|
+
previous?: PreviousLayerData[];
|
|
106
|
+
} = {},
|
|
107
|
+
): Promise<{ url: string; head: Head }> {
|
|
108
|
+
const previous = options.previous ?? this.state.layers;
|
|
109
|
+
const url = options.url ?? this.url;
|
|
110
|
+
|
|
111
|
+
this.transitioning = { to: url };
|
|
112
|
+
|
|
113
|
+
const result = await this.router.transition(
|
|
114
|
+
new URL(`http://localhost${url}`),
|
|
115
|
+
{
|
|
116
|
+
previous,
|
|
117
|
+
state: this.state,
|
|
118
|
+
},
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
if (result.redirect) {
|
|
122
|
+
return await this.render({ url: result.redirect });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
this.transitioning = undefined;
|
|
126
|
+
|
|
127
|
+
return { url, head: result.head };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Get embedded layers from the server.
|
|
132
|
+
*
|
|
133
|
+
* @protected
|
|
134
|
+
*/
|
|
135
|
+
protected getHydrationState(): ReactHydrationState | undefined {
|
|
136
|
+
try {
|
|
137
|
+
if ("__ssr" in window && typeof window.__ssr === "object") {
|
|
138
|
+
return window.__ssr as ReactHydrationState;
|
|
139
|
+
}
|
|
140
|
+
} catch (error) {
|
|
141
|
+
console.error(error);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
*
|
|
147
|
+
* @protected
|
|
148
|
+
*/
|
|
149
|
+
protected getRootElement() {
|
|
150
|
+
const root = this.document.getElementById(this.env.REACT_ROOT_ID);
|
|
151
|
+
if (root) {
|
|
152
|
+
return root;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const div = this.document.createElement("div");
|
|
156
|
+
div.id = this.env.REACT_ROOT_ID;
|
|
157
|
+
|
|
158
|
+
this.document.body.prepend(div);
|
|
159
|
+
|
|
160
|
+
return div;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
*
|
|
167
|
+
* @protected
|
|
168
|
+
*/
|
|
169
|
+
protected ready = $hook({
|
|
170
|
+
name: "ready",
|
|
171
|
+
handler: async () => {
|
|
172
|
+
const hydration = this.getHydrationState();
|
|
173
|
+
const previous = hydration?.layers ?? [];
|
|
174
|
+
|
|
175
|
+
if (hydration?.links) {
|
|
176
|
+
this.client.links = hydration.links as HttpClientLink[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const { head } = await this.render({ previous });
|
|
180
|
+
if (head) {
|
|
181
|
+
this.headProvider.renderHead(this.document, head);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const context = {};
|
|
185
|
+
|
|
186
|
+
await this.alepha.emit("react:browser:render", {
|
|
187
|
+
context,
|
|
188
|
+
hydration,
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const element = this.router.root(this.state, context);
|
|
192
|
+
|
|
193
|
+
if (previous.length > 0) {
|
|
194
|
+
this.root = hydrateRoot(this.getRootElement(), element);
|
|
195
|
+
this.log.info("Hydrated root element");
|
|
196
|
+
} else {
|
|
197
|
+
this.root ??= createRoot(this.getRootElement());
|
|
198
|
+
this.root.render(element);
|
|
199
|
+
this.log.info("Created root element");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
window.addEventListener("popstate", () => {
|
|
203
|
+
this.render();
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
this.router.events.on("end", ({ head }) => {
|
|
207
|
+
this.headProvider.renderHead(this.document, head);
|
|
208
|
+
});
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
export interface RouterGoOptions {
|
|
216
|
+
replace?: boolean;
|
|
217
|
+
match?: TransitionOptions;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export interface ReactHydrationState {
|
|
221
|
+
layers?: PreviousLayerData[];
|
|
222
|
+
links?: HttpClientLink[];
|
|
223
|
+
}
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { $hook, $inject, $logger, Alepha, type Static, t } from "@alepha/core";
|
|
5
|
+
import {
|
|
6
|
+
type ServerHandler,
|
|
7
|
+
ServerLinksProvider,
|
|
8
|
+
ServerRouterProvider,
|
|
9
|
+
} from "@alepha/server";
|
|
10
|
+
import { ServerStaticProvider } from "@alepha/server-static";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
import { $page } from "../descriptors/$page.ts";
|
|
13
|
+
import {
|
|
14
|
+
PageDescriptorProvider,
|
|
15
|
+
type PageRequest,
|
|
16
|
+
type PageRoute,
|
|
17
|
+
} from "./PageDescriptorProvider.ts";
|
|
18
|
+
import { ServerHeadProvider } from "./ServerHeadProvider.ts";
|
|
19
|
+
|
|
20
|
+
export const envSchema = t.object({
|
|
21
|
+
REACT_SERVER_DIST: t.string({ default: "client" }),
|
|
22
|
+
REACT_SERVER_PREFIX: t.string({ default: "" }),
|
|
23
|
+
REACT_SSR_ENABLED: t.boolean({ default: false }),
|
|
24
|
+
REACT_ROOT_ID: t.string({ default: "root" }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
declare module "@alepha/core" {
|
|
28
|
+
interface Env extends Partial<Static<typeof envSchema>> {}
|
|
29
|
+
interface State {
|
|
30
|
+
"ReactServerProvider.template"?: string;
|
|
31
|
+
"ReactServerProvider.ssr"?: boolean;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export class ReactServerProvider {
|
|
36
|
+
protected readonly log = $logger();
|
|
37
|
+
protected readonly alepha = $inject(Alepha);
|
|
38
|
+
protected readonly pageDescriptorProvider = $inject(PageDescriptorProvider);
|
|
39
|
+
protected readonly serverStaticProvider = $inject(ServerStaticProvider);
|
|
40
|
+
protected readonly serverRouterProvider = $inject(ServerRouterProvider);
|
|
41
|
+
protected readonly headProvider = $inject(ServerHeadProvider);
|
|
42
|
+
protected readonly env = $inject(envSchema);
|
|
43
|
+
protected readonly ROOT_DIV_REGEX = new RegExp(
|
|
44
|
+
`<div([^>]*)\\s+id=["']${this.env.REACT_ROOT_ID}["']([^>]*)>(.*?)<\\/div>`,
|
|
45
|
+
"is",
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
protected readonly configure = $hook({
|
|
49
|
+
name: "configure",
|
|
50
|
+
handler: async () => {
|
|
51
|
+
const pages = this.alepha.getDescriptorValues($page);
|
|
52
|
+
if (pages.length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
for (const { key, instance, value } of pages) {
|
|
57
|
+
const name = value.options.name ?? key;
|
|
58
|
+
|
|
59
|
+
if (this.alepha.isTest()) {
|
|
60
|
+
instance[key].render = this.createRenderFunction(name);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (this.alepha.isServerless() === "vite") {
|
|
65
|
+
await this.configureVite();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let root = "";
|
|
70
|
+
if (!this.alepha.isServerless()) {
|
|
71
|
+
root = this.getPublicDirectory();
|
|
72
|
+
|
|
73
|
+
if (!root) {
|
|
74
|
+
this.log.warn("Missing static files, SSR will be disabled");
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
await this.configureStaticServer(root);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const template =
|
|
82
|
+
this.alepha.state("ReactServerProvider.template") ??
|
|
83
|
+
(await readFile(join(root, "index.html"), "utf-8"));
|
|
84
|
+
|
|
85
|
+
await this.registerPages(async () => template);
|
|
86
|
+
|
|
87
|
+
this.alepha.state("ReactServerProvider.ssr", true);
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
protected async registerPages(
|
|
92
|
+
templateLoader: () => Promise<string | undefined>,
|
|
93
|
+
) {
|
|
94
|
+
for (const page of this.pageDescriptorProvider.getPages()) {
|
|
95
|
+
this.log.debug(`+ ${page.match} -> ${page.name}`);
|
|
96
|
+
await this.serverRouterProvider.route({
|
|
97
|
+
method: "GET",
|
|
98
|
+
path: page.match,
|
|
99
|
+
handler: this.createHandler(page, templateLoader),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
protected getPublicDirectory(): string {
|
|
105
|
+
const maybe = [
|
|
106
|
+
join(process.cwd(), this.env.REACT_SERVER_DIST),
|
|
107
|
+
join(process.cwd(), "..", this.env.REACT_SERVER_DIST),
|
|
108
|
+
];
|
|
109
|
+
|
|
110
|
+
for (const it of maybe) {
|
|
111
|
+
if (existsSync(it)) {
|
|
112
|
+
return it;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return "";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
protected async configureStaticServer(root: string) {
|
|
120
|
+
await this.serverStaticProvider.serve({
|
|
121
|
+
root,
|
|
122
|
+
path: this.env.REACT_SERVER_PREFIX,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
protected async configureVite() {
|
|
127
|
+
const url = `http://${process.env.SERVER_HOST}:${process.env.SERVER_PORT}`;
|
|
128
|
+
this.log.info("SSR (vite) OK");
|
|
129
|
+
this.alepha.state("ReactServerProvider.ssr", true);
|
|
130
|
+
const templateUrl = `${url}/index.html`;
|
|
131
|
+
|
|
132
|
+
await this.registerPages(() =>
|
|
133
|
+
fetch(templateUrl)
|
|
134
|
+
.then((it) => it.text())
|
|
135
|
+
.catch(() => undefined),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
protected createRenderFunction(name: string) {
|
|
140
|
+
return async (
|
|
141
|
+
options: {
|
|
142
|
+
params?: Record<string, string>;
|
|
143
|
+
query?: Record<string, string>;
|
|
144
|
+
} = {},
|
|
145
|
+
) => {
|
|
146
|
+
const page = this.pageDescriptorProvider.page(name);
|
|
147
|
+
|
|
148
|
+
const state = await this.pageDescriptorProvider.createLayers(page, {
|
|
149
|
+
url: new URL("http://localhost"),
|
|
150
|
+
params: options.params ?? {},
|
|
151
|
+
query: options.query ?? {},
|
|
152
|
+
head: {},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
return renderToString(this.pageDescriptorProvider.root(state));
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
protected createHandler(
|
|
160
|
+
page: PageRoute,
|
|
161
|
+
templateLoader: () => Promise<string | undefined>,
|
|
162
|
+
): ServerHandler {
|
|
163
|
+
return async (serverRequest) => {
|
|
164
|
+
const { url, reply, query, params } = serverRequest;
|
|
165
|
+
const template = await templateLoader();
|
|
166
|
+
if (!template) {
|
|
167
|
+
throw new Error("Template not found");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const request: PageRequest = {
|
|
171
|
+
url,
|
|
172
|
+
params,
|
|
173
|
+
query,
|
|
174
|
+
head: {},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// -- links
|
|
178
|
+
if (this.alepha.has(ServerLinksProvider)) {
|
|
179
|
+
const srv = this.alepha.get(ServerLinksProvider);
|
|
180
|
+
request.links = (await srv.links()) as any;
|
|
181
|
+
this.alepha.als.set("links", request.links);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await this.alepha.emit(
|
|
185
|
+
"react:server:render",
|
|
186
|
+
{
|
|
187
|
+
request: serverRequest,
|
|
188
|
+
pageRequest: request,
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
log: false,
|
|
192
|
+
},
|
|
193
|
+
);
|
|
194
|
+
|
|
195
|
+
const state = await this.pageDescriptorProvider.createLayers(
|
|
196
|
+
page,
|
|
197
|
+
request,
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
if (state.redirect) {
|
|
201
|
+
return reply.redirect(state.redirect);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const element = this.pageDescriptorProvider.root(state, request);
|
|
205
|
+
const app = renderToString(element);
|
|
206
|
+
|
|
207
|
+
// create hydration data
|
|
208
|
+
const script = `<script>window.__ssr=${JSON.stringify({
|
|
209
|
+
links: request.links,
|
|
210
|
+
layers: state.layers.map((it) => ({
|
|
211
|
+
...it,
|
|
212
|
+
error: it.error
|
|
213
|
+
? {
|
|
214
|
+
...it.error,
|
|
215
|
+
name: it.error.name,
|
|
216
|
+
message: it.error.message,
|
|
217
|
+
stack: it.error.stack, // TODO: Hide stack in production ?
|
|
218
|
+
}
|
|
219
|
+
: undefined,
|
|
220
|
+
index: undefined,
|
|
221
|
+
path: undefined,
|
|
222
|
+
element: undefined,
|
|
223
|
+
})),
|
|
224
|
+
})}</script>`;
|
|
225
|
+
|
|
226
|
+
const response = {
|
|
227
|
+
html: template,
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
reply.status = 200;
|
|
231
|
+
reply.headers["content-type"] = "text/html";
|
|
232
|
+
reply.headers["cache-control"] =
|
|
233
|
+
"no-store, no-cache, must-revalidate, proxy-revalidate";
|
|
234
|
+
reply.headers.pragma = "no-cache";
|
|
235
|
+
reply.headers.expires = "0";
|
|
236
|
+
|
|
237
|
+
// inject app into template
|
|
238
|
+
this.fillTemplate(response, app, script);
|
|
239
|
+
|
|
240
|
+
// inject head meta
|
|
241
|
+
if (state.head) {
|
|
242
|
+
response.html = this.headProvider.renderHead(response.html, state.head);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// TODO: hook for plugins "react:server:template"
|
|
246
|
+
// { response: { html: string }, request, state }
|
|
247
|
+
|
|
248
|
+
return response.html;
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
fillTemplate(response: { html: string }, app: string, script: string) {
|
|
253
|
+
if (this.ROOT_DIV_REGEX.test(response.html)) {
|
|
254
|
+
// replace contents of the existing <div id="root">
|
|
255
|
+
response.html = response.html.replace(
|
|
256
|
+
this.ROOT_DIV_REGEX,
|
|
257
|
+
(_match, beforeId, afterId) => {
|
|
258
|
+
return `<div${beforeId} id="${this.env.REACT_ROOT_ID}"${afterId}>${app}</div>`;
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
} else {
|
|
262
|
+
const bodyOpenTag = /<body([^>]*)>/i;
|
|
263
|
+
if (bodyOpenTag.test(response.html)) {
|
|
264
|
+
response.html = response.html.replace(bodyOpenTag, (match) => {
|
|
265
|
+
return `${match}\n<div id="${this.env.REACT_ROOT_ID}">${app}</div>`;
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const bodyCloseTagRegex = /<\/body>/i;
|
|
271
|
+
if (bodyCloseTagRegex.test(response.html)) {
|
|
272
|
+
response.html = response.html.replace(
|
|
273
|
+
bodyCloseTagRegex,
|
|
274
|
+
`${script}\n</body>`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
export interface Head {
|
|
2
|
+
title?: string;
|
|
3
|
+
htmlAttributes?: Record<string, string>;
|
|
4
|
+
bodyAttributes?: Record<string, string>;
|
|
5
|
+
meta?: Array<{ name: string; content: string }>;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export class ServerHeadProvider {
|
|
9
|
+
renderHead(template: string, head: Head): string {
|
|
10
|
+
let result = template;
|
|
11
|
+
|
|
12
|
+
// Inject htmlAttributes
|
|
13
|
+
const htmlAttributes = head.htmlAttributes;
|
|
14
|
+
if (htmlAttributes) {
|
|
15
|
+
result = result.replace(
|
|
16
|
+
/<html([^>]*)>/i,
|
|
17
|
+
(_, existingAttrs) =>
|
|
18
|
+
`<html${this.mergeAttributes(existingAttrs, htmlAttributes)}>`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Inject bodyAttributes
|
|
23
|
+
const bodyAttributes = head.bodyAttributes;
|
|
24
|
+
if (bodyAttributes) {
|
|
25
|
+
result = result.replace(
|
|
26
|
+
/<body([^>]*)>/i,
|
|
27
|
+
(_, existingAttrs) =>
|
|
28
|
+
`<body${this.mergeAttributes(existingAttrs, bodyAttributes)}>`,
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Build head content
|
|
33
|
+
let headContent = "";
|
|
34
|
+
const title = head.title;
|
|
35
|
+
if (title) {
|
|
36
|
+
if (template.includes("<title>")) {
|
|
37
|
+
result = result.replace(
|
|
38
|
+
/<title>(.*?)<\/title>/i,
|
|
39
|
+
() => `<title>${this.escapeHtml(title)}</title>`,
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
headContent += `<title>${this.escapeHtml(title)}</title>\n`;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (head.meta) {
|
|
47
|
+
for (const meta of head.meta) {
|
|
48
|
+
headContent += `<meta name="${this.escapeHtml(meta.name)}" content="${this.escapeHtml(meta.content)}">\n`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Inject into <head>...</head>
|
|
53
|
+
result = result.replace(
|
|
54
|
+
/<head([^>]*)>(.*?)<\/head>/is,
|
|
55
|
+
(_, existingAttrs, existingHead) =>
|
|
56
|
+
`<head${existingAttrs}>${existingHead}${headContent}</head>`,
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
return result.trim();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
mergeAttributes(existing: string, attrs: Record<string, string>): string {
|
|
63
|
+
const existingAttrs = this.parseAttributes(existing);
|
|
64
|
+
const merged = { ...existingAttrs, ...attrs };
|
|
65
|
+
return Object.entries(merged)
|
|
66
|
+
.map(([k, v]) => ` ${k}="${this.escapeHtml(v)}"`)
|
|
67
|
+
.join("");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
parseAttributes(attrStr: string): Record<string, string> {
|
|
71
|
+
const attrs: Record<string, string> = {};
|
|
72
|
+
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g;
|
|
73
|
+
let match: RegExpExecArray | null = attrRegex.exec(attrStr);
|
|
74
|
+
|
|
75
|
+
while (match) {
|
|
76
|
+
attrs[match[1]] = match[2] ?? "";
|
|
77
|
+
match = attrRegex.exec(attrStr);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return attrs;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
escapeHtml(str: string): string {
|
|
84
|
+
return str
|
|
85
|
+
.replace(/&/g, "&")
|
|
86
|
+
.replace(/</g, "<")
|
|
87
|
+
.replace(/>/g, ">")
|
|
88
|
+
.replace(/"/g, """)
|
|
89
|
+
.replace(/'/g, "'");
|
|
90
|
+
}
|
|
91
|
+
}
|