dh-remixer-sdk 0.0.29-eafb9f2 → 0.0.29-eb18fb8
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/package.json +6 -4
- package/scripts/sdk-update/index.mjs +1 -0
- package/scripts/ssg-helmet/extract-routes.mjs +1 -1
- package/scripts/ssg-helmet/fs-utils.mjs +5 -8
- package/scripts/ssg-helmet/has-router.mjs +1 -1
- package/scripts/ssg-helmet/index.mjs +18 -34
- package/scripts/ssg-helmet/inject-metadata-head.mjs +345 -0
- package/scripts/ssg-helmet/inject-styles-head.mjs +38 -0
- package/scripts/ssg-helmet/parse-html-for-contact-info.mjs +159 -0
- package/scripts/ssg-helmet/prerenderer.mjs +232 -89
- package/scripts/ssg-helmet/vars.mjs +32 -1
- package/templates/base/index.html +10 -2
- package/templates/base/vite.config.ts +21 -0
- package/templates/immersive/Stage3D.tsx +26 -6
- package/templates/immersive/safeCanvas.tsx +11 -0
- package/scripts/ssg-helmet/build-helmet-html.mjs +0 -330
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dh-remixer-sdk",
|
|
3
|
-
"version": "0.0.29-
|
|
3
|
+
"version": "0.0.29-eb18fb8",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"templates",
|
|
@@ -12,17 +12,19 @@
|
|
|
12
12
|
"sdk-update": "./scripts/sdk-update/index.mjs"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
+
"@sparticuz/chromium": "^131.0.1",
|
|
15
16
|
"acorn": "8.14.0",
|
|
16
17
|
"acorn-jsx": "5.3.2",
|
|
17
18
|
"chrome-launcher": "1.2.1",
|
|
18
19
|
"chrome-remote-interface": "0.34.0",
|
|
19
20
|
"estree-walker": "3.0.3",
|
|
21
|
+
"prettier": "^3.8.3",
|
|
20
22
|
"sucrase": "3.35.0"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
23
|
-
"eslint-plugin-n": "17.24.0",
|
|
24
|
-
"globals": "17.4.0",
|
|
25
25
|
"eslint": "9.0.0",
|
|
26
|
-
"eslint-plugin-import": "2.32.0"
|
|
26
|
+
"eslint-plugin-import": "2.32.0",
|
|
27
|
+
"eslint-plugin-n": "17.24.0",
|
|
28
|
+
"globals": "17.4.0"
|
|
27
29
|
}
|
|
28
30
|
}
|
|
@@ -49,6 +49,7 @@ async function main() {
|
|
|
49
49
|
// Extract new files
|
|
50
50
|
await Promise.all([
|
|
51
51
|
...Object.entries(extractionList).map(([fileName, target]) => {
|
|
52
|
+
console.log(`Extracting ${fileName}`);
|
|
52
53
|
if (fileName.endsWith(".json")) {
|
|
53
54
|
return extractJsonFile(fileName, target, templateType);
|
|
54
55
|
}
|
|
@@ -48,7 +48,7 @@ export async function extractRoutesFromRouterFile(filePath) {
|
|
|
48
48
|
const src = await fs.readFile(filePath, "utf8");
|
|
49
49
|
let code = src;
|
|
50
50
|
try {
|
|
51
|
-
code = transform(src, { transforms: ["typescript"] }).code;
|
|
51
|
+
code = transform(src, { transforms: ["typescript", "jsx"], jsxRuntime: "preserve" }).code;
|
|
52
52
|
} catch {}
|
|
53
53
|
|
|
54
54
|
const ast = Parser.parse(code, { ecmaVersion: "latest", sourceType: "module" });
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { injectUnpackRedirectScript } from "./inject-unpack-redirect.mjs";
|
|
4
|
-
import {
|
|
4
|
+
import { injectMetadata } from "./inject-metadata-head.mjs";
|
|
5
|
+
import { injectStylesHead } from "./inject-styles-head.mjs";
|
|
5
6
|
import { ROOT_ROUTE, REMIXER_BUILD_DIR } from "./vars.mjs";
|
|
6
7
|
|
|
7
8
|
async function writeFile(route, content) {
|
|
@@ -12,13 +13,9 @@ async function writeFile(route, content) {
|
|
|
12
13
|
|
|
13
14
|
export async function writeHelmetFile(html, currentRoute, isIndexable) {
|
|
14
15
|
const idxTag = isIndexable ? "indexable" : "not_indexable";
|
|
15
|
-
if (currentRoute === ROOT_ROUTE) {
|
|
16
|
-
await injectUnpackRedirectScript();
|
|
17
|
-
console.log(`[crawled][${idxTag}][edited] ${currentRoute}index.html`);
|
|
18
|
-
return;
|
|
19
|
-
}
|
|
20
16
|
|
|
21
|
-
const
|
|
22
|
-
await
|
|
17
|
+
const metadataHtml = await injectMetadata(html, currentRoute, isIndexable);
|
|
18
|
+
const styledHtml = await injectStylesHead(metadataHtml);
|
|
19
|
+
await writeFile(currentRoute, styledHtml);
|
|
23
20
|
console.log(`[crawled][${idxTag}][created] ${currentRoute}/index.html`);
|
|
24
21
|
}
|
|
@@ -53,7 +53,7 @@ export function hasRouter(src) {
|
|
|
53
53
|
// parse (TS -> JS) then AST scan; if we can't parse, assume false
|
|
54
54
|
let code = src;
|
|
55
55
|
try {
|
|
56
|
-
code = transform(src, { transforms: ["typescript"] }).code;
|
|
56
|
+
code = transform(src, { transforms: ["typescript", "jsx"], jsxRuntime: "preserve" }).code;
|
|
57
57
|
} catch {}
|
|
58
58
|
|
|
59
59
|
let ast;
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
TARGET_DOMAIN,
|
|
19
19
|
REMIXER_BUILD_DIR,
|
|
20
20
|
} from "./vars.mjs";
|
|
21
|
+
import {
|
|
22
|
+
parseHtmlForContactInfo,
|
|
23
|
+
applyBusinessLdJson,
|
|
24
|
+
} from "./parse-html-for-contact-info.mjs";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Handles:
|
|
@@ -30,40 +34,12 @@ async function main() {
|
|
|
30
34
|
const visistedRoutes = [];
|
|
31
35
|
let queuedRoutes = [ROOT_ROUTE];
|
|
32
36
|
const reactRoutes = await getRoutes();
|
|
33
|
-
let sitemapRoutes = [
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
console.log(`[ssg-helmet] Creating Prerenderer (attempt ${attempt}/${maxRetries})...`);
|
|
41
|
-
prerenderer = await Prerenderer.create();
|
|
42
|
-
console.log(`[ssg-helmet] Prerenderer created successfully`);
|
|
43
|
-
break;
|
|
44
|
-
} catch (err) {
|
|
45
|
-
console.error(`[ssg-helmet] Prerenderer.create() failed on attempt ${attempt}:`, err.message);
|
|
46
|
-
|
|
47
|
-
// Clean up any partially-created prerenderer
|
|
48
|
-
if (prerenderer) {
|
|
49
|
-
try {
|
|
50
|
-
await prerenderer.close();
|
|
51
|
-
} catch (closeErr) {
|
|
52
|
-
console.error(`[ssg-helmet] Error during cleanup:`, closeErr.message);
|
|
53
|
-
}
|
|
54
|
-
prerenderer = null;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
if (attempt === maxRetries) {
|
|
58
|
-
throw new Error(`Failed to create Prerenderer after ${maxRetries} attempts: ${err.message}`);
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Exponential backoff: 2s, 4s
|
|
62
|
-
const delay = attempt * 2000;
|
|
63
|
-
console.log(`[ssg-helmet] Retrying in ${delay}ms...`);
|
|
64
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
65
|
-
}
|
|
66
|
-
}
|
|
37
|
+
let sitemapRoutes = [
|
|
38
|
+
ROOT_ROUTE,
|
|
39
|
+
reactRoutes.find((i) => typeof i === "object"),
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const prerenderer = await Prerenderer.create();
|
|
67
43
|
|
|
68
44
|
while (queuedRoutes.length > 0) {
|
|
69
45
|
const knownRoutes = [...queuedRoutes, ...visistedRoutes];
|
|
@@ -72,6 +48,7 @@ async function main() {
|
|
|
72
48
|
queuedRoutes.map(async (currentRoute) => {
|
|
73
49
|
const html = await prerenderer.prerenderPage(currentRoute);
|
|
74
50
|
await writeHelmetFile(html, currentRoute, IS_INDEXABLE);
|
|
51
|
+
await parseHtmlForContactInfo(html);
|
|
75
52
|
return populateRoutesFromHtml(html, sitemapRoutes);
|
|
76
53
|
}),
|
|
77
54
|
);
|
|
@@ -92,6 +69,8 @@ async function main() {
|
|
|
92
69
|
}),
|
|
93
70
|
);
|
|
94
71
|
|
|
72
|
+
await applyBusinessLdJson();
|
|
73
|
+
|
|
95
74
|
const sitemapXml = buildSitemapXml(TARGET_DOMAIN, sitemapRoutes);
|
|
96
75
|
await fs.writeFile(
|
|
97
76
|
path.join(REMIXER_BUILD_DIR, "sitemap.xml"),
|
|
@@ -111,6 +90,11 @@ Sitemap: https://${TARGET_DOMAIN}/sitemap.xml
|
|
|
111
90
|
);
|
|
112
91
|
console.log("[created] robots.txt");
|
|
113
92
|
|
|
93
|
+
// Build succeeded: drop the hidden SPA-bootstrap sidecar so it never ships
|
|
94
|
+
// in the deployed output. (On a crash we skip this and leave it behind so a
|
|
95
|
+
// re-run can recover the clobbered index.html.)
|
|
96
|
+
await prerenderer.removeBootstrapBackup();
|
|
97
|
+
|
|
114
98
|
await prerenderer.close();
|
|
115
99
|
}
|
|
116
100
|
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simplified redirect-helmet builder:
|
|
3
|
+
* - Extracts metadata from prerenderedHtml (<title>, <meta>, <link>, <base>, ld+json)
|
|
4
|
+
* - Normalizes into a single "meta bag" with common SEO keys
|
|
5
|
+
* - Fills missing fields from available data (og/title/twitter propagation, canonical, og:url, etc.)
|
|
6
|
+
* - Preserves existing application/ld+json scripts; if none exist AND indexable, emits fallback WebPage JSON-LD
|
|
7
|
+
* - Forces robots to "noindex, follow" when isIndexable=false
|
|
8
|
+
* - Ensures <html lang="en">
|
|
9
|
+
* - Canonical is DOMAIN + route (3rd param), not window.location
|
|
10
|
+
* - Avoids double-escaping (&amp;) by decoding extracted text before output
|
|
11
|
+
* - Ensures JSON-LD contains real characters (e.g. "&" not "&")
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as prettier from "prettier";
|
|
15
|
+
import { SINGLETON_LD_JSON_STATE, TARGET_DOMAIN } from "./vars.mjs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import fs from "node:fs/promises";
|
|
18
|
+
|
|
19
|
+
const pick = (re, s) => s.match(re)?.[1] ?? "";
|
|
20
|
+
|
|
21
|
+
const escapeAttr = (s) =>
|
|
22
|
+
String(s)
|
|
23
|
+
.replace(/&/g, "&")
|
|
24
|
+
.replace(/"/g, """)
|
|
25
|
+
.replace(/</g, "<")
|
|
26
|
+
.replace(/>/g, ">");
|
|
27
|
+
|
|
28
|
+
// Decode a few common HTML entities so we don't double-escape values
|
|
29
|
+
// (We only use this on text we extracted from existing HTML.)
|
|
30
|
+
const decodeHtml = (s) =>
|
|
31
|
+
String(s ?? "")
|
|
32
|
+
.replace(/&/gi, "&")
|
|
33
|
+
.replace(/"/gi, '"')
|
|
34
|
+
.replace(/'/gi, "'")
|
|
35
|
+
.replace(/</gi, "<")
|
|
36
|
+
.replace(/>/gi, ">");
|
|
37
|
+
|
|
38
|
+
// Prevent JSON containing "</script>" from breaking the script tag
|
|
39
|
+
export const safeJson = (obj) =>
|
|
40
|
+
JSON.stringify(obj).replace(/<\/script>/gi, "<\\/script>");
|
|
41
|
+
|
|
42
|
+
const parseAttrs = (tag) => {
|
|
43
|
+
const attrs = {};
|
|
44
|
+
const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s/>]+)))?/g;
|
|
45
|
+
let m;
|
|
46
|
+
while ((m = re.exec(tag))) {
|
|
47
|
+
const k = m[1].toLowerCase();
|
|
48
|
+
const v = m[2] ?? m[3] ?? m[4] ?? "";
|
|
49
|
+
attrs[k] = v;
|
|
50
|
+
}
|
|
51
|
+
return attrs;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const setFirst = (obj, k, v) => {
|
|
55
|
+
if (v == null) return;
|
|
56
|
+
const s = String(v).trim();
|
|
57
|
+
if (!s) return;
|
|
58
|
+
if (!obj[k]) obj[k] = s;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const setForce = (obj, k, v) => {
|
|
62
|
+
const s = String(v ?? "").trim();
|
|
63
|
+
if (!s) return;
|
|
64
|
+
obj[k] = s;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const normalizeDomain = (d) => {
|
|
68
|
+
const s = String(d || "").trim();
|
|
69
|
+
if (!s) return "";
|
|
70
|
+
if (/^https?:\/\//i.test(s)) return s.replace(/\/+$/, "");
|
|
71
|
+
return `https://${s.replace(/\/+$/, "")}`;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const normalizeRoute = (r) => {
|
|
75
|
+
let p = String(r || "/").trim();
|
|
76
|
+
if (!p) p = "/";
|
|
77
|
+
if (!p.startsWith("/")) p = "/" + p;
|
|
78
|
+
// strip query/hash, normalize trailing slash
|
|
79
|
+
p = p.split("?")[0].split("#")[0];
|
|
80
|
+
p = p.replace(/\/+$/, "") || "/";
|
|
81
|
+
return p;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const canonicalFromDomainRoute = (route) =>
|
|
85
|
+
normalizeDomain(TARGET_DOMAIN) + normalizeRoute(route);
|
|
86
|
+
|
|
87
|
+
async function getType() {
|
|
88
|
+
const packageJsonPath = path.join("./package.json");
|
|
89
|
+
const content = await fs.readFile(packageJsonPath, "utf-8");
|
|
90
|
+
|
|
91
|
+
if (!content) {
|
|
92
|
+
return "WebPage";
|
|
93
|
+
}
|
|
94
|
+
const packageJsonData = JSON.parse(content);
|
|
95
|
+
|
|
96
|
+
if (packageJsonData.name === "remixer-ecommerce") {
|
|
97
|
+
return "OnlineStore";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return "WebPage";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function injectMetadata(
|
|
104
|
+
prerenderedHtml,
|
|
105
|
+
route = "/",
|
|
106
|
+
isIndexable = true,
|
|
107
|
+
) {
|
|
108
|
+
if (typeof prerenderedHtml !== "string") {
|
|
109
|
+
throw new TypeError("prerenderedHtml must be a string");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// --- helpers --------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
// --- extract head ---------------------------------------------------------
|
|
115
|
+
const lang = "en";
|
|
116
|
+
|
|
117
|
+
const headInner = pick(/<head\b[^>]*>([\s\S]*?)<\/head>/i, prerenderedHtml);
|
|
118
|
+
|
|
119
|
+
// decode to avoid double-escape on output
|
|
120
|
+
const titleText = decodeHtml(
|
|
121
|
+
pick(/<title\b[^>]*>([\s\S]*?)<\/title>/i, headInner).trim(),
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const metaTags = headInner.match(/<meta\b[^>]*>/gi) || [];
|
|
125
|
+
const linkTags = headInner.match(/<link\b[^>]*>/gi) || [];
|
|
126
|
+
const headScripts =
|
|
127
|
+
headInner.match(/<script\b[^>]*>[\s\S]*?<\/script>/gi) || [];
|
|
128
|
+
const ldJsonScripts = headScripts.filter((s) =>
|
|
129
|
+
/type\s*=\s*(['"])application\/ld\+json\1/i.test(s),
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// The app bootstrap (module bundle + any inline boot scripts vite emitted).
|
|
133
|
+
// Preserving these lets the helmet page boot the live SPA: the prerendered
|
|
134
|
+
// HTML is the first paint / crawler view, then React mounts on top so the
|
|
135
|
+
// page is fully interactive and framer-motion animations actually run.
|
|
136
|
+
const bootstrapScripts = headScripts.filter(
|
|
137
|
+
(s) => !/type\s*=\s*(['"])application\/ld\+json\1/i.test(s),
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Preserve non-CSS, non-canonical links (modulepreload, icon, preconnect,
|
|
141
|
+
// dns-prefetch, ...). Stylesheets are dropped here because injectStylesHead
|
|
142
|
+
// re-emits them as <link rel="stylesheet"> tags built from the CSS files on
|
|
143
|
+
// disk; canonical is re-emitted from the meta bag below.
|
|
144
|
+
const preservedLinks = linkTags.filter((tag) => {
|
|
145
|
+
const rel = (parseAttrs(tag).rel || "").toLowerCase();
|
|
146
|
+
return rel !== "stylesheet" && rel !== "canonical";
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// --- build meta bag -------------------------------------------------------
|
|
150
|
+
const meta = {
|
|
151
|
+
title: "",
|
|
152
|
+
description: "",
|
|
153
|
+
robots: "",
|
|
154
|
+
canonical: "",
|
|
155
|
+
charset: "",
|
|
156
|
+
viewport: "",
|
|
157
|
+
"theme-color": "",
|
|
158
|
+
|
|
159
|
+
"og:title": "",
|
|
160
|
+
"og:description": "",
|
|
161
|
+
"og:image": "",
|
|
162
|
+
"og:image:alt": "",
|
|
163
|
+
"og:type": "",
|
|
164
|
+
"og:url": "",
|
|
165
|
+
"og:site_name": "",
|
|
166
|
+
"og:locale": "",
|
|
167
|
+
|
|
168
|
+
"twitter:card": "",
|
|
169
|
+
"twitter:title": "",
|
|
170
|
+
"twitter:description": "",
|
|
171
|
+
"twitter:image": "",
|
|
172
|
+
"twitter:image:alt": "",
|
|
173
|
+
"twitter:site": "",
|
|
174
|
+
"twitter:creator": "",
|
|
175
|
+
|
|
176
|
+
"application-name": "",
|
|
177
|
+
"apple-mobile-web-app-title": "",
|
|
178
|
+
"apple-mobile-web-app-capable": "",
|
|
179
|
+
"apple-mobile-web-app-status-bar-style": "",
|
|
180
|
+
"format-detection": "",
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
setFirst(meta, "title", titleText);
|
|
184
|
+
|
|
185
|
+
// metas
|
|
186
|
+
for (const tag of metaTags) {
|
|
187
|
+
const attrs = parseAttrs(tag);
|
|
188
|
+
|
|
189
|
+
if (attrs.charset) {
|
|
190
|
+
setFirst(meta, "charset", decodeHtml(attrs.charset));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const name = (attrs.name || "").toLowerCase();
|
|
194
|
+
const prop = (attrs.property || "").toLowerCase();
|
|
195
|
+
const httpEquiv = (attrs["http-equiv"] || "").toLowerCase();
|
|
196
|
+
const content = decodeHtml(attrs.content ?? "");
|
|
197
|
+
|
|
198
|
+
if (name === "description") {
|
|
199
|
+
setFirst(meta, "description", content);
|
|
200
|
+
} else if (name === "robots") {
|
|
201
|
+
setFirst(meta, "robots", content);
|
|
202
|
+
} else if (name === "viewport") {
|
|
203
|
+
setFirst(meta, "viewport", content);
|
|
204
|
+
} else if (name === "theme-color") {
|
|
205
|
+
setFirst(meta, "'theme-color'", content);
|
|
206
|
+
} else if (name && name in meta) {
|
|
207
|
+
setFirst(meta, name, content);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (prop && prop in meta) setFirst(meta, prop, content);
|
|
211
|
+
|
|
212
|
+
if (httpEquiv === "content-language") setFirst(meta, "og:locale", content);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// canonical from <link rel="canonical"...> if present in source
|
|
216
|
+
for (const tag of linkTags) {
|
|
217
|
+
const attrs = parseAttrs(tag);
|
|
218
|
+
const rel = (attrs.rel || "").toLowerCase();
|
|
219
|
+
|
|
220
|
+
// console.log(tag);
|
|
221
|
+
|
|
222
|
+
if (rel === "canonical" && attrs.href) {
|
|
223
|
+
setFirst(meta, "canonical", decodeHtml(attrs.href));
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- fill fallbacks -------------------------------------------------------
|
|
228
|
+
setFirst(meta, "og:title", meta.title);
|
|
229
|
+
setFirst(meta, "og:description", meta.description);
|
|
230
|
+
|
|
231
|
+
// If canonical is set (or will be), use it for og:url
|
|
232
|
+
// (We'll set canonical later if missing.)
|
|
233
|
+
setFirst(meta, "twitter:title", meta["og:title"] || meta.title);
|
|
234
|
+
setFirst(
|
|
235
|
+
meta,
|
|
236
|
+
"twitter:description",
|
|
237
|
+
meta["og:description"] || meta.description,
|
|
238
|
+
);
|
|
239
|
+
|
|
240
|
+
// image propagation
|
|
241
|
+
setFirst(meta, "twitter:image", meta["og:image"]);
|
|
242
|
+
setFirst(meta, "twitter:image:alt", meta["og:image:alt"]);
|
|
243
|
+
setFirst(meta, "og:image:alt", meta["twitter:image:alt"]);
|
|
244
|
+
|
|
245
|
+
// twitter card default
|
|
246
|
+
setFirst(
|
|
247
|
+
meta,
|
|
248
|
+
"twitter:card",
|
|
249
|
+
meta["twitter:image"] ? "summary_large_image" : "summary",
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
// --- canonical: always DOMAIN + route unless source provided one ----------
|
|
253
|
+
if (!meta.canonical) {
|
|
254
|
+
meta.canonical = canonicalFromDomainRoute(route);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- og:url: set to canonical if missing ---------------------------------
|
|
258
|
+
if (!meta["og:url"]) {
|
|
259
|
+
meta["og:url"] = meta.canonical;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// --- enforce indexability -------------------------------------------------
|
|
263
|
+
if (!isIndexable) {
|
|
264
|
+
setForce(meta, "robots", "noindex, follow");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// --- emit tags ------------------------------------------------------------
|
|
268
|
+
const out = [];
|
|
269
|
+
for (const [tagName, value] of Object.entries(meta)) {
|
|
270
|
+
if (value.length === 0) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
if (tagName === "charset") {
|
|
275
|
+
out.push(`<meta charset="${escapeAttr(value || "utf-8")}" />`);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (tagName === "canonical") {
|
|
280
|
+
out.push(`<link rel="canonical" href="${escapeAttr(meta.canonical)}" />`);
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (tagName === "title") {
|
|
285
|
+
out.push(`<title>${escapeAttr(meta.title)}</title>`);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
out.push(`<meta property="${tagName}" content="${escapeAttr(value)}" />`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// keep original link/base tags too (favicons, preconnect, modulepreload, ...)
|
|
293
|
+
// NOTE: kept as-is; they may already contain & etc.
|
|
294
|
+
out.push(...preservedLinks);
|
|
295
|
+
|
|
296
|
+
if (ldJsonScripts.length) {
|
|
297
|
+
out.push(...ldJsonScripts);
|
|
298
|
+
} else {
|
|
299
|
+
const pageLdJson = {
|
|
300
|
+
...SINGLETON_LD_JSON_STATE,
|
|
301
|
+
name: meta.title || undefined,
|
|
302
|
+
description: meta.description || undefined,
|
|
303
|
+
"@type": await getType(),
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
out.push(
|
|
307
|
+
`<script type="application/ld+json">${safeJson(pageLdJson)}</script>`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
out.push(
|
|
312
|
+
'<meta name="generator" content="Remixer (https://panel.dreamhost.com/ai-editor)">',
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// App bootstrap last, so the SPA mounts on top of the prerendered content.
|
|
316
|
+
out.push(...bootstrapScripts);
|
|
317
|
+
|
|
318
|
+
const bodyTag = pick(/(<body\b[^>]*>)/i, prerenderedHtml) || "<body>";
|
|
319
|
+
const bodyInner = pick(/<body\b[^>]*>([\s\S]*?)<\/body>/i, prerenderedHtml);
|
|
320
|
+
|
|
321
|
+
const revealCleanupScript = `<script>(function(){var s=document.getElementById("ssg-helmet-reveal");if(!s)return;var r=document.getElementById("root");if(!r){s.remove();return;}var o=new MutationObserver(function(){s.remove();o.disconnect();});o.observe(r,{childList:true});})();</script>`;
|
|
322
|
+
|
|
323
|
+
const rawHtml = `<!doctype html>
|
|
324
|
+
<html lang="${escapeAttr(lang)}">
|
|
325
|
+
<head>
|
|
326
|
+
${out.filter(Boolean).join("\n")}
|
|
327
|
+
</head>
|
|
328
|
+
${bodyTag}
|
|
329
|
+
${bodyInner || ""}
|
|
330
|
+
${revealCleanupScript}
|
|
331
|
+
</body>
|
|
332
|
+
</html>`;
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
return await prettier.format(rawHtml, {
|
|
336
|
+
parser: "html",
|
|
337
|
+
printWidth: 120,
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
console.error(
|
|
341
|
+
`[ssg-helmet] [format ${route}] prettier failed, writing unformatted HTML: ${err.message}`,
|
|
342
|
+
);
|
|
343
|
+
return rawHtml;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { REMIXER_BUILD_DIR } from "./vars.mjs";
|
|
4
|
+
|
|
5
|
+
async function findCssFiles(dir) {
|
|
6
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
7
|
+
|
|
8
|
+
const files = await Promise.all(
|
|
9
|
+
entries.map(async (entry) => {
|
|
10
|
+
const fullPath = path.join(dir, entry.name);
|
|
11
|
+
|
|
12
|
+
if (entry.isDirectory()) {
|
|
13
|
+
return findCssFiles(fullPath);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return entry.name.endsWith(".css") ? fullPath : null;
|
|
17
|
+
}),
|
|
18
|
+
);
|
|
19
|
+
|
|
20
|
+
return files.flat().filter(Boolean);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function injectStylesHead(metadataHtml) {
|
|
24
|
+
const cssFiles = await findCssFiles(REMIXER_BUILD_DIR);
|
|
25
|
+
|
|
26
|
+
// The build dir is served at "/", so a file on disk at
|
|
27
|
+
// REMIXER_BUILD_DIR/assets/x.css is fetched by the browser as /assets/x.css.
|
|
28
|
+
// Emit a stylesheet <link> per file rather than inlining the CSS.
|
|
29
|
+
const linkTags = cssFiles
|
|
30
|
+
.map((file) => {
|
|
31
|
+
const href =
|
|
32
|
+
"/" + path.relative(REMIXER_BUILD_DIR, file).split(path.sep).join("/");
|
|
33
|
+
return `<link rel="stylesheet" href="${href}" />`;
|
|
34
|
+
})
|
|
35
|
+
.join("\n");
|
|
36
|
+
|
|
37
|
+
return metadataHtml.replace("</head>", `\t${linkTags}\n\t</head>`);
|
|
38
|
+
}
|