dh-remixer-sdk 0.0.17 → 0.0.18-aaf644b
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 +5 -2
- package/scripts/{postinstall.mjs → postinstall/index.mjs} +14 -4
- package/scripts/ssg-helmet/build-helmet-html.mjs +325 -0
- package/scripts/ssg-helmet/build-sitemap-xml.mjs +75 -0
- package/scripts/ssg-helmet/extract-routes.mjs +82 -0
- package/scripts/ssg-helmet/fs-utils.mjs +26 -0
- package/scripts/ssg-helmet/get-routes.mjs +52 -0
- package/scripts/ssg-helmet/has-router.mjs +110 -0
- package/scripts/ssg-helmet/index.mjs +74 -0
- package/scripts/ssg-helmet/inject-unpack-redirect.mjs +60 -0
- package/scripts/ssg-helmet/populate-sitemap.mjs +208 -0
- package/scripts/ssg-helmet/prerenderer.mjs +186 -0
- package/templates/base/package.json +8 -2
- package/templates/ecommerce/cleanup.json +2 -1
- package/templates/ecommerce/filemap.json +2 -1
- package/templates/ecommerce/shipping.ts +116 -0
- package/templates/ecommerce/stripe-checkout.ts +24 -1
package/package.json
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dh-remixer-sdk",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.18-aaf644b",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"files": [
|
|
6
6
|
"templates",
|
|
7
7
|
"scripts",
|
|
8
8
|
"bin"
|
|
9
9
|
],
|
|
10
|
+
"bin": {
|
|
11
|
+
"ssg-helmet": "./ssg-helmet/index.mjs"
|
|
12
|
+
},
|
|
10
13
|
"scripts": {
|
|
11
|
-
"postinstall": "node scripts/postinstall.mjs"
|
|
14
|
+
"postinstall": "node scripts/postinstall/index.mjs"
|
|
12
15
|
}
|
|
13
16
|
}
|
|
@@ -95,15 +95,26 @@ async function main() {
|
|
|
95
95
|
const projectRoot = path.resolve(process.env.INIT_CWD ?? process.cwd());
|
|
96
96
|
const projPkgPath = path.join(projectRoot, "package.json");
|
|
97
97
|
const projPkg = JSON.parse(await fs.readFile(projPkgPath, "utf8"));
|
|
98
|
-
|
|
98
|
+
|
|
99
|
+
if (!projPkg) {
|
|
100
|
+
console.error(
|
|
101
|
+
`Cannot find package.json at ${projPkgPath}\nContents: ${JSON.stringify(projPkg)}`,
|
|
102
|
+
);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const templateType = projPkg.remixerMetadata?.template;
|
|
107
|
+
|
|
99
108
|
if (!templateType) {
|
|
100
|
-
console.
|
|
109
|
+
console.error(
|
|
110
|
+
`Missing remixerMetadata.template in package.json\nContents: ${JSON.stringify(projPkg)}`,
|
|
111
|
+
);
|
|
101
112
|
return;
|
|
102
113
|
}
|
|
103
114
|
|
|
104
115
|
const sdkRoot = path.resolve(
|
|
105
116
|
path.dirname(fileURLToPath(import.meta.url)),
|
|
106
|
-
"
|
|
117
|
+
"../..",
|
|
107
118
|
);
|
|
108
119
|
const sdkPkgPath = path.join(sdkRoot, "package.json");
|
|
109
120
|
const sdkVersion = JSON.parse(await fs.readFile(sdkPkgPath, "utf8")).version;
|
|
@@ -158,4 +169,3 @@ async function main() {
|
|
|
158
169
|
}
|
|
159
170
|
|
|
160
171
|
await main();
|
|
161
|
-
|
|
@@ -0,0 +1,325 @@
|
|
|
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
|
+
const DOMAIN = "example.com";
|
|
15
|
+
|
|
16
|
+
export function buildRedirectHelmetHtml(
|
|
17
|
+
prerenderedHtml,
|
|
18
|
+
route = "/",
|
|
19
|
+
isIndexable = true,
|
|
20
|
+
) {
|
|
21
|
+
if (typeof prerenderedHtml !== "string") {
|
|
22
|
+
throw new TypeError("prerenderedHtml must be a string");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// --- helpers --------------------------------------------------------------
|
|
26
|
+
const pick = (re, s) => s.match(re)?.[1] ?? "";
|
|
27
|
+
|
|
28
|
+
const escapeAttr = (s) =>
|
|
29
|
+
String(s)
|
|
30
|
+
.replace(/&/g, "&")
|
|
31
|
+
.replace(/"/g, """)
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">");
|
|
34
|
+
|
|
35
|
+
// Decode a few common HTML entities so we don't double-escape values
|
|
36
|
+
// (We only use this on text we extracted from existing HTML.)
|
|
37
|
+
const decodeHtml = (s) =>
|
|
38
|
+
String(s ?? "")
|
|
39
|
+
.replace(/&/gi, "&")
|
|
40
|
+
.replace(/"/gi, '"')
|
|
41
|
+
.replace(/'/gi, "'")
|
|
42
|
+
.replace(/</gi, "<")
|
|
43
|
+
.replace(/>/gi, ">");
|
|
44
|
+
|
|
45
|
+
// Prevent JSON containing "</script>" from breaking the script tag
|
|
46
|
+
const safeJson = (obj) =>
|
|
47
|
+
JSON.stringify(obj).replace(/<\/script>/gi, "<\\/script>");
|
|
48
|
+
|
|
49
|
+
const parseAttrs = (tag) => {
|
|
50
|
+
const attrs = {};
|
|
51
|
+
const re = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s/>]+)))?/g;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = re.exec(tag))) {
|
|
54
|
+
const k = m[1].toLowerCase();
|
|
55
|
+
const v = m[2] ?? m[3] ?? m[4] ?? "";
|
|
56
|
+
attrs[k] = v;
|
|
57
|
+
}
|
|
58
|
+
return attrs;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const setFirst = (obj, k, v) => {
|
|
62
|
+
if (v == null) return;
|
|
63
|
+
const s = String(v).trim();
|
|
64
|
+
if (!s) return;
|
|
65
|
+
if (!obj[k]) obj[k] = s;
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const setForce = (obj, k, v) => {
|
|
69
|
+
const s = String(v ?? "").trim();
|
|
70
|
+
if (!s) return;
|
|
71
|
+
obj[k] = s;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const normalizeDomain = (d) => {
|
|
75
|
+
const s = String(d || "").trim();
|
|
76
|
+
if (!s) return "";
|
|
77
|
+
if (/^https?:\/\//i.test(s)) return s.replace(/\/+$/, "");
|
|
78
|
+
return `https://${s.replace(/\/+$/, "")}`;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const normalizeRoute = (r) => {
|
|
82
|
+
let p = String(r || "/").trim();
|
|
83
|
+
if (!p) p = "/";
|
|
84
|
+
if (!p.startsWith("/")) p = "/" + p;
|
|
85
|
+
// strip query/hash, normalize trailing slash
|
|
86
|
+
p = p.split("?")[0].split("#")[0];
|
|
87
|
+
p = p.replace(/\/+$/, "") || "/";
|
|
88
|
+
return p;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const canonicalFromDomainRoute = () => normalizeDomain(DOMAIN) + normalizeRoute(route);
|
|
92
|
+
|
|
93
|
+
// --- extract head ---------------------------------------------------------
|
|
94
|
+
const lang = "en";
|
|
95
|
+
|
|
96
|
+
const headInner = pick(/<head\b[^>]*>([\s\S]*?)<\/head>/i, prerenderedHtml);
|
|
97
|
+
|
|
98
|
+
// decode to avoid double-escape on output
|
|
99
|
+
const titleText = decodeHtml(
|
|
100
|
+
pick(/<title\b[^>]*>([\s\S]*?)<\/title>/i, headInner).trim(),
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const metaTags = headInner.match(/<meta\b[^>]*>/gi) || [];
|
|
104
|
+
const linkTags = headInner.match(/<link\b[^>]*>/gi) || [];
|
|
105
|
+
const baseTags = headInner.match(/<base\b[^>]*>/gi) || [];
|
|
106
|
+
const ldJsonScripts =
|
|
107
|
+
headInner.match(
|
|
108
|
+
/<script\b[^>]*type\s*=\s*(['"])application\/ld\+json\1[^>]*>[\s\S]*?<\/script>/gi,
|
|
109
|
+
) || [];
|
|
110
|
+
|
|
111
|
+
// --- build meta bag -------------------------------------------------------
|
|
112
|
+
const meta = {
|
|
113
|
+
title: "",
|
|
114
|
+
description: "",
|
|
115
|
+
robots: "",
|
|
116
|
+
canonical: "",
|
|
117
|
+
charset: "",
|
|
118
|
+
viewport: "",
|
|
119
|
+
themeColor: "",
|
|
120
|
+
|
|
121
|
+
"og:title": "",
|
|
122
|
+
"og:description": "",
|
|
123
|
+
"og:image": "",
|
|
124
|
+
"og:image:alt": "",
|
|
125
|
+
"og:type": "",
|
|
126
|
+
"og:url": "",
|
|
127
|
+
"og:site_name": "",
|
|
128
|
+
"og:locale": "",
|
|
129
|
+
|
|
130
|
+
"twitter:card": "",
|
|
131
|
+
"twitter:title": "",
|
|
132
|
+
"twitter:description": "",
|
|
133
|
+
"twitter:image": "",
|
|
134
|
+
"twitter:image:alt": "",
|
|
135
|
+
"twitter:site": "",
|
|
136
|
+
"twitter:creator": "",
|
|
137
|
+
|
|
138
|
+
"application-name": "",
|
|
139
|
+
"apple-mobile-web-app-title": "",
|
|
140
|
+
"apple-mobile-web-app-capable": "",
|
|
141
|
+
"apple-mobile-web-app-status-bar-style": "",
|
|
142
|
+
"format-detection": "",
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
setFirst(meta, "title", titleText);
|
|
146
|
+
|
|
147
|
+
// metas
|
|
148
|
+
for (const t of metaTags) {
|
|
149
|
+
const a = parseAttrs(t);
|
|
150
|
+
|
|
151
|
+
if (a.charset) setFirst(meta, "charset", decodeHtml(a.charset));
|
|
152
|
+
|
|
153
|
+
const name = (a.name || "").toLowerCase();
|
|
154
|
+
const prop = (a.property || "").toLowerCase();
|
|
155
|
+
const httpEquiv = (a["http-equiv"] || "").toLowerCase();
|
|
156
|
+
const content = decodeHtml(a.content ?? "");
|
|
157
|
+
|
|
158
|
+
if (name === "description") setFirst(meta, "description", content);
|
|
159
|
+
else if (name === "robots") setFirst(meta, "robots", content);
|
|
160
|
+
else if (name === "viewport") setFirst(meta, "viewport", content);
|
|
161
|
+
else if (name === "theme-color") setFirst(meta, "themeColor", content);
|
|
162
|
+
else if (name && name in meta) setFirst(meta, name, content);
|
|
163
|
+
|
|
164
|
+
if (prop && prop in meta) setFirst(meta, prop, content);
|
|
165
|
+
|
|
166
|
+
if (httpEquiv === "content-language") setFirst(meta, "og:locale", content);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// canonical from <link rel="canonical"...> if present in source
|
|
170
|
+
for (const t of linkTags) {
|
|
171
|
+
const a = parseAttrs(t);
|
|
172
|
+
const rel = (a.rel || "").toLowerCase();
|
|
173
|
+
if (rel === "canonical" && a.href) setFirst(meta, "canonical", decodeHtml(a.href));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- fill fallbacks -------------------------------------------------------
|
|
177
|
+
setFirst(meta, "og:title", meta.title);
|
|
178
|
+
setFirst(meta, "og:description", meta.description);
|
|
179
|
+
|
|
180
|
+
// If canonical is set (or will be), use it for og:url
|
|
181
|
+
// (We'll set canonical later if missing.)
|
|
182
|
+
setFirst(meta, "twitter:title", meta["og:title"] || meta.title);
|
|
183
|
+
setFirst(meta, "twitter:description", meta["og:description"] || meta.description);
|
|
184
|
+
|
|
185
|
+
// image propagation
|
|
186
|
+
setFirst(meta, "twitter:image", meta["og:image"]);
|
|
187
|
+
setFirst(meta, "twitter:image:alt", meta["og:image:alt"]);
|
|
188
|
+
setFirst(meta, "og:image:alt", meta["twitter:image:alt"]);
|
|
189
|
+
|
|
190
|
+
// twitter card default
|
|
191
|
+
setFirst(
|
|
192
|
+
meta,
|
|
193
|
+
"twitter:card",
|
|
194
|
+
meta["twitter:image"] ? "summary_large_image" : "summary",
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// --- canonical: always DOMAIN + route unless source provided one ----------
|
|
198
|
+
if (!meta.canonical) {
|
|
199
|
+
meta.canonical = canonicalFromDomainRoute();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// --- og:url: set to canonical if missing ---------------------------------
|
|
203
|
+
if (!meta["og:url"]) {
|
|
204
|
+
meta["og:url"] = meta.canonical;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// --- enforce indexability -------------------------------------------------
|
|
208
|
+
if (!isIndexable) {
|
|
209
|
+
setForce(meta, "robots", "noindex, follow");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// --- emit tags ------------------------------------------------------------
|
|
213
|
+
const out = [];
|
|
214
|
+
|
|
215
|
+
out.push(`<meta charset="${escapeAttr(meta.charset || "utf-8")}" />`);
|
|
216
|
+
|
|
217
|
+
if (meta.viewport)
|
|
218
|
+
out.push(`<meta name="viewport" content="${escapeAttr(meta.viewport)}" />`);
|
|
219
|
+
|
|
220
|
+
if (meta.robots)
|
|
221
|
+
out.push(`<meta name="robots" content="${escapeAttr(meta.robots)}" />`);
|
|
222
|
+
|
|
223
|
+
if (meta.description)
|
|
224
|
+
out.push(`<meta name="description" content="${escapeAttr(meta.description)}" />`);
|
|
225
|
+
|
|
226
|
+
if (meta.themeColor)
|
|
227
|
+
out.push(`<meta name="theme-color" content="${escapeAttr(meta.themeColor)}" />`);
|
|
228
|
+
|
|
229
|
+
if (meta["application-name"])
|
|
230
|
+
out.push(
|
|
231
|
+
`<meta name="application-name" content="${escapeAttr(meta["application-name"])}" />`,
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
if (meta.title) out.push(`<title>${escapeAttr(meta.title)}</title>`);
|
|
235
|
+
|
|
236
|
+
if (meta.canonical)
|
|
237
|
+
out.push(`<link rel="canonical" href="${escapeAttr(meta.canonical)}" />`);
|
|
238
|
+
|
|
239
|
+
for (const k of [
|
|
240
|
+
"og:title",
|
|
241
|
+
"og:description",
|
|
242
|
+
"og:image",
|
|
243
|
+
"og:image:alt",
|
|
244
|
+
"og:type",
|
|
245
|
+
"og:url",
|
|
246
|
+
"og:site_name",
|
|
247
|
+
"og:locale",
|
|
248
|
+
]) {
|
|
249
|
+
if (meta[k]) out.push(`<meta property="${k}" content="${escapeAttr(meta[k])}" />`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const k of [
|
|
253
|
+
"twitter:card",
|
|
254
|
+
"twitter:title",
|
|
255
|
+
"twitter:description",
|
|
256
|
+
"twitter:image",
|
|
257
|
+
"twitter:image:alt",
|
|
258
|
+
"twitter:site",
|
|
259
|
+
"twitter:creator",
|
|
260
|
+
]) {
|
|
261
|
+
if (meta[k]) out.push(`<meta name="${k}" content="${escapeAttr(meta[k])}" />`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// keep original link/base tags too (favicons, preconnect, etc.)
|
|
265
|
+
// NOTE: These tags may include & already; but they're raw tags and should be kept as-is.
|
|
266
|
+
out.push(...linkTags);
|
|
267
|
+
out.push(...baseTags);
|
|
268
|
+
|
|
269
|
+
// --- JSON-LD: preserve existing; else generate fallback -------------------
|
|
270
|
+
if (ldJsonScripts.length) {
|
|
271
|
+
out.push(...ldJsonScripts);
|
|
272
|
+
} else if (isIndexable) {
|
|
273
|
+
// IMPORTANT: JSON-LD strings must NOT contain HTML entities.
|
|
274
|
+
const fallbackLdJson = {
|
|
275
|
+
"@context": "https://schema.org",
|
|
276
|
+
"@type": "WebPage",
|
|
277
|
+
name: meta.title || undefined,
|
|
278
|
+
description: meta.description || undefined,
|
|
279
|
+
url: meta.canonical || undefined,
|
|
280
|
+
inLanguage: "en",
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
for (const k of Object.keys(fallbackLdJson)) {
|
|
284
|
+
if (fallbackLdJson[k] === undefined) delete fallbackLdJson[k];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
out.push(
|
|
288
|
+
`<script type="application/ld+json">${safeJson(fallbackLdJson)}</script>`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- redirect script ------------------------------------------------------
|
|
293
|
+
out.push(
|
|
294
|
+
`<script type="text/javascript">
|
|
295
|
+
(function () {
|
|
296
|
+
var botPattern =
|
|
297
|
+
"(googlebot\\\\/|bot|bingbot|slurp|DuckDuckBot|baiduspider|yandexbot|facebookexternalhit|twitterbot|linkedinbot|pinterest|slackbot|discordbot|whatsapp|telegrambot|embedly|quora link preview|applebot|semrushbot|ahrefsbot|mj12bot|dotbot)";
|
|
298
|
+
var re = new RegExp(botPattern, "i");
|
|
299
|
+
var ua = navigator.userAgent;
|
|
300
|
+
|
|
301
|
+
if (!re.test(ua)) {
|
|
302
|
+
var l = window.location;
|
|
303
|
+
l.replace(
|
|
304
|
+
l.protocol +
|
|
305
|
+
"//" +
|
|
306
|
+
l.hostname +
|
|
307
|
+
(l.port ? ":" + l.port : "") +
|
|
308
|
+
"/?p=/" +
|
|
309
|
+
l.pathname.slice(1).replace(/&/g, "~and~") +
|
|
310
|
+
(l.search ? "&q=" + l.search.slice(1).replace(/&/g, "~and~") : "") +
|
|
311
|
+
l.hash
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
})();
|
|
315
|
+
</script>`,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return `<!doctype html>
|
|
319
|
+
<html lang="${escapeAttr(lang)}">
|
|
320
|
+
<head>
|
|
321
|
+
${out.filter(Boolean).join("\n")}
|
|
322
|
+
</head>
|
|
323
|
+
<body></body>
|
|
324
|
+
</html>`;
|
|
325
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
const PRIORITY_HOME = 1.0;
|
|
2
|
+
const PRIORITY_NORMAL = 0.8;
|
|
3
|
+
const PRIORITY_TEMPLATED = 0.6;
|
|
4
|
+
|
|
5
|
+
const CHANGEFREQ_HOME = "daily";
|
|
6
|
+
const CHANGEFREQ_NORMAL = "weekly";
|
|
7
|
+
const CHANGEFREQ_TEMPLATED = "weekly";
|
|
8
|
+
|
|
9
|
+
export function buildSitemapXml(origin, routes) {
|
|
10
|
+
if (!/^https?:\/\//i.test(origin)) origin = "https://" + origin;
|
|
11
|
+
const cleanOrigin = origin.replace(/\/+$/, "");
|
|
12
|
+
const today = new Date().toISOString().slice(0, 10); // YYYY-MM-DD
|
|
13
|
+
|
|
14
|
+
const abs = (route) => `${cleanOrigin}${route === "/" ? "/" : route}`;
|
|
15
|
+
const esc = (s) =>
|
|
16
|
+
String(s)
|
|
17
|
+
.replace(/&/g, "&")
|
|
18
|
+
.replace(/</g, "<")
|
|
19
|
+
.replace(/>/g, ">")
|
|
20
|
+
.replace(/"/g, """)
|
|
21
|
+
.replace(/'/g, "'");
|
|
22
|
+
|
|
23
|
+
const entries = [];
|
|
24
|
+
for (const item of routes) {
|
|
25
|
+
if (typeof item === "string") {
|
|
26
|
+
entries.push({ path: item, kind: item === "/" ? "home" : "normal" });
|
|
27
|
+
} else if (item && typeof item === "object") {
|
|
28
|
+
for (const concrete of Object.values(item)) {
|
|
29
|
+
if (!Array.isArray(concrete)) continue;
|
|
30
|
+
for (const r of concrete) entries.push({ path: r, kind: "templated" });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const rank = { home: 3, normal: 2, templated: 1 };
|
|
36
|
+
const byPath = new Map();
|
|
37
|
+
for (const e of entries) {
|
|
38
|
+
const prev = byPath.get(e.path);
|
|
39
|
+
if (!prev || rank[e.kind] > rank[prev.kind]) byPath.set(e.path, e);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const ordered = Array.from(byPath.values()).sort((a, b) => {
|
|
43
|
+
const rk = rank[b.kind] - rank[a.kind];
|
|
44
|
+
return rk || a.path.localeCompare(b.path);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const node = ({ path, kind }) => {
|
|
48
|
+
const priority =
|
|
49
|
+
kind === "home"
|
|
50
|
+
? PRIORITY_HOME
|
|
51
|
+
: kind === "templated"
|
|
52
|
+
? PRIORITY_TEMPLATED
|
|
53
|
+
: PRIORITY_NORMAL;
|
|
54
|
+
|
|
55
|
+
const changefreq =
|
|
56
|
+
kind === "home"
|
|
57
|
+
? CHANGEFREQ_HOME
|
|
58
|
+
: kind === "templated"
|
|
59
|
+
? CHANGEFREQ_TEMPLATED
|
|
60
|
+
: CHANGEFREQ_NORMAL;
|
|
61
|
+
|
|
62
|
+
return ` <url>
|
|
63
|
+
<loc>${esc(abs(path))}</loc>
|
|
64
|
+
<lastmod>${today}</lastmod>
|
|
65
|
+
<changefreq>${changefreq}</changefreq>
|
|
66
|
+
<priority>${priority.toFixed(1)}</priority>
|
|
67
|
+
</url>`;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
71
|
+
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
|
72
|
+
${ordered.map(node).join("\n")}
|
|
73
|
+
</urlset>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// extract-routes.mjs
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import * as acorn from "acorn";
|
|
4
|
+
import jsx from "acorn-jsx";
|
|
5
|
+
import { transform } from "sucrase";
|
|
6
|
+
|
|
7
|
+
const Parser = acorn.Parser.extend(jsx());
|
|
8
|
+
|
|
9
|
+
const tagName = (n) =>
|
|
10
|
+
n?.type === "JSXIdentifier" ? n.name : n?.type === "JSXMemberExpression" ? n.property?.name : null;
|
|
11
|
+
|
|
12
|
+
const attrName = (a) => (a?.name?.type === "JSXIdentifier" ? a.name.name : null);
|
|
13
|
+
|
|
14
|
+
const strVal = (v) => {
|
|
15
|
+
if (!v) return null;
|
|
16
|
+
if (v.type === "Literal" && typeof v.value === "string") return v.value;
|
|
17
|
+
if (v.type === "JSXExpressionContainer") {
|
|
18
|
+
const e = v.expression;
|
|
19
|
+
if (e?.type === "Literal" && typeof e.value === "string") return e.value;
|
|
20
|
+
if (e?.type === "TemplateLiteral" && (e.expressions?.length ?? 0) === 0)
|
|
21
|
+
return e.quasis?.[0]?.value?.cooked ?? null;
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const walk = (n, f) => {
|
|
27
|
+
if (!n || typeof n !== "object") return;
|
|
28
|
+
f(n);
|
|
29
|
+
for (const k in n) {
|
|
30
|
+
const v = n[k];
|
|
31
|
+
if (Array.isArray(v)) v.forEach((x) => walk(x, f));
|
|
32
|
+
else if (v && typeof v.type === "string") walk(v, f);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const isTemplatedRoute = (route) => route.split("/").some((seg) => seg.startsWith(":"));
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns routes as:
|
|
40
|
+
* - strings for normal routes
|
|
41
|
+
* - at most ONE object collecting templated routes: { "page/:id": [], "x/:slug": [] }
|
|
42
|
+
*
|
|
43
|
+
* Examples:
|
|
44
|
+
* ["/", "/about"]
|
|
45
|
+
* ["/", "/about", { "product/:id": [], "blog/:slug": [] }]
|
|
46
|
+
*/
|
|
47
|
+
export async function extractRoutesFromRouterFile(filePath) {
|
|
48
|
+
const src = await fs.readFile(filePath, "utf8");
|
|
49
|
+
let code = src;
|
|
50
|
+
try {
|
|
51
|
+
code = transform(src, { transforms: ["typescript"] }).code;
|
|
52
|
+
} catch {}
|
|
53
|
+
|
|
54
|
+
const ast = Parser.parse(code, { ecmaVersion: "latest", sourceType: "module" });
|
|
55
|
+
|
|
56
|
+
const all = new Set();
|
|
57
|
+
|
|
58
|
+
walk(ast, (node) => {
|
|
59
|
+
if (node.type !== "JSXOpeningElement") return;
|
|
60
|
+
if (tagName(node.name) !== "Route") return;
|
|
61
|
+
|
|
62
|
+
for (const a of node.attributes ?? []) {
|
|
63
|
+
if (a.type !== "JSXAttribute") continue;
|
|
64
|
+
if (attrName(a) !== "path") continue;
|
|
65
|
+
const p = strVal(a.value);
|
|
66
|
+
if (p) all.add(p);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const routes = all.size ? [...all] : ["/"];
|
|
71
|
+
|
|
72
|
+
const normal = [];
|
|
73
|
+
const templated = {};
|
|
74
|
+
|
|
75
|
+
for (const r of routes) {
|
|
76
|
+
if (typeof r !== "string") continue;
|
|
77
|
+
if (isTemplatedRoute(r)) templated[r] = [];
|
|
78
|
+
else normal.push(r);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return Object.keys(templated).length ? [...normal, templated] : normal;
|
|
82
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { injectUnpackRedirectScript } from "./inject-unpack-redirect.mjs";
|
|
4
|
+
import { buildRedirectHelmetHtml } from "./build-helmet-html.mjs";
|
|
5
|
+
|
|
6
|
+
const ROOT = "/";
|
|
7
|
+
const OUT = path.resolve("./out");
|
|
8
|
+
|
|
9
|
+
async function writeFile(route, content) {
|
|
10
|
+
const productOut = path.join(OUT, route, "index.html");
|
|
11
|
+
await fs.mkdir(path.dirname(productOut), { recursive: true });
|
|
12
|
+
await fs.writeFile(productOut, content, "utf8");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function writeHelmetFile(html, currentRoute, isIndexable) {
|
|
16
|
+
const idxTag = isIndexable ? "indexable" : "not_indexable";
|
|
17
|
+
if (currentRoute === ROOT) {
|
|
18
|
+
await injectUnpackRedirectScript("out");
|
|
19
|
+
console.log(`[crawled][${idxTag}][edited] ${currentRoute}index.html`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const helmetHtml = buildRedirectHelmetHtml(html, currentRoute, isIndexable);
|
|
24
|
+
await writeFile(currentRoute, helmetHtml);
|
|
25
|
+
console.log(`[crawled][${idxTag}][created] ${currentRoute}/index.html`);
|
|
26
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { hasRouter } from "./has-router.mjs";
|
|
4
|
+
import { extractRoutesFromRouterFile } from "./extract-routes.mjs";
|
|
5
|
+
|
|
6
|
+
const SKIP_DIRS = new Set([
|
|
7
|
+
"node_modules",
|
|
8
|
+
".git",
|
|
9
|
+
"dist",
|
|
10
|
+
"build",
|
|
11
|
+
"out",
|
|
12
|
+
".next",
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
async function getRouterFilePath(dir = "./") {
|
|
16
|
+
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
17
|
+
|
|
18
|
+
for (const e of entries) {
|
|
19
|
+
if (!e.isFile() || !e.name.endsWith(".tsx")) continue;
|
|
20
|
+
|
|
21
|
+
const filePath = path.join(dir, e.name);
|
|
22
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
23
|
+
if (hasRouter(content)) return filePath;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
for (const e of entries) {
|
|
27
|
+
if (!e.isDirectory()) continue;
|
|
28
|
+
if (SKIP_DIRS.has(e.name)) continue;
|
|
29
|
+
|
|
30
|
+
const filePath = await getRouterFilePath(path.join(dir, e.name));
|
|
31
|
+
if (filePath) return filePath;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function getRoutes() {
|
|
38
|
+
const routerFilePath = await getRouterFilePath();
|
|
39
|
+
if (!routerFilePath) return ["/"];
|
|
40
|
+
|
|
41
|
+
return await extractRoutesFromRouterFile(routerFilePath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function routeDifference(A, B) {
|
|
45
|
+
const flat = (arr) =>
|
|
46
|
+
arr.flatMap((x) => (typeof x === "string" ? x : Object.values(x).flat()));
|
|
47
|
+
|
|
48
|
+
const ARoutes = flat(A);
|
|
49
|
+
const BRoutes = flat(B);
|
|
50
|
+
|
|
51
|
+
return ARoutes.filter((route) => !BRoutes.includes(route));
|
|
52
|
+
}
|