dh-remixer-sdk 0.0.45 → 0.0.46-4dc2bca

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dh-remixer-sdk",
3
- "version": "0.0.45",
3
+ "version": "0.0.46-4dc2bca",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "templates",
@@ -23,13 +23,13 @@ const strVal = (v) => {
23
23
  return null;
24
24
  };
25
25
 
26
- const walk = (n, f) => {
26
+ const walk = (n, f, parent) => {
27
27
  if (!n || typeof n !== "object") return;
28
- f(n);
28
+ f(n, parent);
29
29
  for (const k in n) {
30
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);
31
+ if (Array.isArray(v)) v.forEach((x) => walk(x, f, n));
32
+ else if (v && typeof v.type === "string") walk(v, f, n);
33
33
  }
34
34
  };
35
35
 
@@ -76,6 +76,275 @@ const parseSource = (src, filePath = "routes.tsx") => {
76
76
  const calleeName = (callee) =>
77
77
  callee?.type === "Identifier" ? callee.name : callee?.type === "MemberExpression" ? callee.property?.name : null;
78
78
 
79
+ const ROUTER_SOURCES = new Set(["react-router", "react-router-dom"]);
80
+ const SINGLE_PAGE_PACKAGES = new Set(["react", "react-helmet-async", "lucide-react"]);
81
+ const SINGLE_PAGE_REACT_APIS = new Set([
82
+ "Fragment", "StrictMode", "createContext", "useContext", "useState", "useReducer",
83
+ "useEffect", "useLayoutEffect", "useInsertionEffect", "useMemo", "useCallback", "useRef",
84
+ "useId", "useTransition", "useDeferredValue", "useDebugValue", "useSyncExternalStore",
85
+ ]);
86
+ const OPAQUE_RENDER_PROPS = new Set([
87
+ "ref", "dangerouslySetInnerHTML", "srcDoc", "script", "noscript", "htmlAttributes", "bodyAttributes",
88
+ ]);
89
+ const OPAQUE_ELEMENTS = new Set(["script", "iframe", "object", "embed", "html", "body"]);
90
+ const PATH_STATE_NAMES = new Set([
91
+ "window", "document", "globalThis", "self", "top", "parent", "frames",
92
+ "location", "history", "navigation", "pathname", "hash", "require", "eval", "Function",
93
+ "constructor", "__proto__", "createElement", "cloneElement",
94
+ ]);
95
+ const GLOBAL_NAMES = new Set([
96
+ "undefined", "NaN", "Infinity", "Array", "Object", "String", "Number", "Boolean",
97
+ "Math", "JSON", "Date", "Intl", "Map", "Set", "RegExp", "Error", "console",
98
+ ]);
99
+ const isFunction = (node) =>
100
+ ["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(node?.type);
101
+ const isJsx = (node) => ["JSXElement", "JSXFragment"].includes(node?.type);
102
+ const definesComponent = (node) => isFunction(node) || ["ClassDeclaration", "ClassExpression"].includes(node?.type);
103
+ const importedName = (specifier) =>
104
+ specifier.type === "ImportDefaultSpecifier" ? "default" : specifier.imported?.name ?? "*";
105
+
106
+ const bindPattern = (node, bindings) => {
107
+ if (!node) return;
108
+ if (node.type === "Identifier") bindings.add(node);
109
+ else if (node.type === "RestElement") bindPattern(node.argument, bindings);
110
+ else if (node.type === "AssignmentPattern") bindPattern(node.left, bindings);
111
+ else if (node.type === "ArrayPattern") node.elements.forEach((element) => bindPattern(element, bindings));
112
+ else if (node.type === "ObjectPattern") {
113
+ node.properties.forEach((property) => bindPattern(property.value ?? property.argument, bindings));
114
+ }
115
+ };
116
+
117
+ const functionReturn = (node) => {
118
+ if (!isFunction(node) || node.async || node.generator || node.params.length) return null;
119
+ if (node.body.type !== "BlockStatement") return node.body;
120
+ const last = node.body.body.at(-1);
121
+ let returns = 0;
122
+ const visit = (child) => {
123
+ if (!child || typeof child !== "object" || isFunction(child)) return;
124
+ if (child.type === "ReturnStatement") returns += 1;
125
+ for (const value of Object.values(child)) {
126
+ if (Array.isArray(value)) value.forEach(visit);
127
+ else if (value && typeof value.type === "string") visit(value);
128
+ }
129
+ };
130
+ visit(node.body);
131
+ return returns === 1 && last?.type === "ReturnStatement" ? last.argument : null;
132
+ };
133
+
134
+ const isNullLocationHelper = (ast, component) => {
135
+ const returned = functionReturn(component);
136
+ if (returned?.type !== "Literal" || returned.value !== null || component.body.type !== "BlockStatement") return false;
137
+ if (ast.body.some((node) =>
138
+ !["ImportDeclaration", "ExportDefaultDeclaration", "FunctionDeclaration"].includes(node.type))) return false;
139
+ if (ast.body.filter((node) => node.type === "FunctionDeclaration").some((node) => node !== component)) return false;
140
+ const imports = ast.body.filter((node) => node.type === "ImportDeclaration");
141
+ if (!imports.some((node) => ROUTER_SOURCES.has(node.source.value) &&
142
+ node.specifiers.some((specifier) => specifier.imported?.name === "useLocation"))) return false;
143
+ if (imports.some((node) => node.source.value !== "react" && !ROUTER_SOURCES.has(node.source.value))) return false;
144
+ if (imports.some((node) => node.specifiers.some((specifier) =>
145
+ specifier.type !== "ImportSpecifier" || specifier.local.name !== specifier.imported.name ||
146
+ !(node.source.value === "react" && specifier.imported.name === "useEffect" ||
147
+ ROUTER_SOURCES.has(node.source.value) && specifier.imported.name === "useLocation")))) return false;
148
+
149
+ const isCall = (node, name) =>
150
+ node?.type === "CallExpression" && node.callee.type === "Identifier" && node.callee.name === name;
151
+ const isLocation = (node) => isCall(node, "useLocation") && node.arguments.length === 0;
152
+ const isScroll = (node) => node?.type === "CallExpression" && node.callee.type === "MemberExpression" &&
153
+ !node.callee.computed && node.callee.object.name === "window" && node.callee.property.name === "scrollTo" &&
154
+ node.arguments.length === 2 &&
155
+ node.arguments.every((argument) => argument.type === "Literal" && typeof argument.value === "number");
156
+ const locations = new Set();
157
+ const pathnames = new Set();
158
+ for (const statement of component.body.body.slice(0, -1)) {
159
+ if (statement.type === "VariableDeclaration" && statement.declarations.length === 1) {
160
+ const { id, init } = statement.declarations[0];
161
+ if (!isLocation(init)) return false;
162
+ if (id.type === "Identifier") locations.add(id.name);
163
+ else if (id.type === "ObjectPattern" && id.properties.length === 1 &&
164
+ id.properties[0].type === "Property" && id.properties[0].key.name === "pathname" &&
165
+ id.properties[0].value.type === "Identifier") pathnames.add(id.properties[0].value.name);
166
+ else return false;
167
+ continue;
168
+ }
169
+ if (statement.type !== "ExpressionStatement") return false;
170
+ const expression = statement.expression;
171
+ if (isLocation(expression)) continue;
172
+ if (!isCall(expression, "useEffect") || expression.arguments.length !== 2 ||
173
+ !imports.some((node) => node.source.value === "react" && node.specifiers.some((specifier) => specifier.imported?.name === "useEffect"))) return false;
174
+ const [effect, dependencies] = expression.arguments;
175
+ if (!isFunction(effect) || effect.params.length || effect.async || effect.generator) return false;
176
+ const effects = effect.body.type === "BlockStatement"
177
+ ? effect.body.body
178
+ : [{ type: "ExpressionStatement", expression: effect.body }];
179
+ if (!effects.length || effects.some((entry) =>
180
+ entry.type !== "ExpressionStatement" || !isScroll(entry.expression))) return false;
181
+ if (dependencies.type !== "ArrayExpression" || dependencies.elements.some((entry) =>
182
+ !(entry?.type === "Identifier" && (locations.has(entry.name) || pathnames.has(entry.name)) ||
183
+ entry?.type === "MemberExpression" && !entry.computed && locations.has(entry.object.name) && entry.property.name === "pathname"))) return false;
184
+ }
185
+ return true;
186
+ };
187
+
188
+ export function singlePageFromSource(src, filePath, trusted = false) {
189
+ const ast = parseSource(src, filePath);
190
+ if (!ast || !ast.body.length) return { eligible: false, app: false, imports: [], exports: [], components: [] };
191
+
192
+ const declarations = new Map();
193
+ const bindings = new Set();
194
+ const imports = [];
195
+ const exportValues = new Map();
196
+ const elements = new Set();
197
+ const writes = new Set();
198
+ walk(ast, (node) => {
199
+ if (node.type === "JSXOpeningElement" && node.name.type === "JSXIdentifier" && !/^[a-z]/.test(node.name.name)) {
200
+ elements.add(node.name.name);
201
+ }
202
+ if (["AssignmentExpression", "ForInStatement", "ForOfStatement"].includes(node.type)) bindPattern(node.left, writes);
203
+ if (node.type === "UpdateExpression") bindPattern(node.argument, writes);
204
+ });
205
+ const written = new Set([...writes].map((node) => node.name));
206
+ const local = (name) => !written.has(name) && declarations.get(name);
207
+ for (const node of ast.body) {
208
+ const declaration = node.declaration ?? node;
209
+ if (declaration.type === "FunctionDeclaration" || declaration.type === "ClassDeclaration") {
210
+ if (declaration.id) declarations.set(declaration.id.name, declaration);
211
+ }
212
+ if (declaration.type === "VariableDeclaration") {
213
+ for (const entry of declaration.declarations) {
214
+ if (entry.id.type === "Identifier") declarations.set(entry.id.name, entry.init);
215
+ }
216
+ }
217
+ if (node.type === "ImportDeclaration") {
218
+ imports.push({
219
+ source: node.source.value,
220
+ names: node.specifiers.map(importedName),
221
+ elements: node.specifiers.filter((specifier) => elements.has(specifier.local.name)).map(importedName),
222
+ });
223
+ node.specifiers.forEach((specifier) => declarations.set(specifier.local.name, specifier));
224
+ }
225
+ if (node.type === "ExportDefaultDeclaration") exportValues.set("default", declaration.id ?? declaration);
226
+ if (node.type === "ExportNamedDeclaration") {
227
+ if (declaration.id) exportValues.set(declaration.id.name, declaration.id);
228
+ if (declaration.type === "VariableDeclaration") {
229
+ for (const entry of declaration.declarations) if (entry.id.type === "Identifier") exportValues.set(entry.id.name, entry.id);
230
+ }
231
+ for (const specifier of node.specifiers) exportValues.set(specifier.exported.name, specifier.local);
232
+ }
233
+ }
234
+ const exportNames = [...exportValues.keys()];
235
+ const components = [...exportValues]
236
+ .filter(([, value]) => definesComponent(value.type === "Identifier" ? local(value.name) : value))
237
+ .map(([name]) => name);
238
+ const exported = ast.body.find((node) => node.type === "ExportDefaultDeclaration")?.declaration;
239
+ const component = exported?.type === "Identifier" ? declarations.get(exported.name) : exported;
240
+ const app = isJsx(functionReturn(component));
241
+ if (trusted || isNullLocationHelper(ast, component)) {
242
+ return { eligible: true, app, imports, exports: exportNames, components };
243
+ }
244
+
245
+ let eligible = true;
246
+ const routerBindings = new Set();
247
+ const reactBindings = new Set();
248
+ for (const node of ast.body.filter((entry) => entry.type === "ImportDeclaration")) {
249
+ const source = node.source.value;
250
+ if (source === "react") {
251
+ if (node.specifiers.some((specifier) => specifier.type === "ImportSpecifier" &&
252
+ !SINGLE_PAGE_REACT_APIS.has(specifier.imported.name))) eligible = false;
253
+ node.specifiers.filter((specifier) => specifier.type !== "ImportSpecifier")
254
+ .forEach((specifier) => reactBindings.add(specifier.local.name));
255
+ }
256
+ if (source.startsWith(".") || source.startsWith("@/")) {
257
+ if (node.specifiers.some((specifier) => specifier.type === "ImportNamespaceSpecifier")) eligible = false;
258
+ } else if (ROUTER_SOURCES.has(source)) {
259
+ if (!node.specifiers.length || node.specifiers.some((specifier) =>
260
+ specifier.type !== "ImportSpecifier" || specifier.imported.name !== "BrowserRouter" || specifier.local.name !== "BrowserRouter")) eligible = false;
261
+ else routerBindings.add("BrowserRouter");
262
+ } else if (!SINGLE_PAGE_PACKAGES.has(source)) eligible = false;
263
+ }
264
+ const parents = new Map();
265
+ const scopes = new Map();
266
+ walk(ast, (node, parent) => parents.set(node, parent));
267
+ const scopeFor = (node, hoisted = false) => {
268
+ while (node && node !== ast && !isFunction(node) &&
269
+ (hoisted || !["BlockStatement", "CatchClause", "ForStatement", "ForOfStatement", "ForInStatement", "SwitchStatement"].includes(node.type))) {
270
+ node = parents.get(node);
271
+ }
272
+ return node ?? ast;
273
+ };
274
+ const declare = (pattern, scope) => {
275
+ const declared = new Set();
276
+ bindPattern(pattern, declared);
277
+ if (!scopes.has(scope)) scopes.set(scope, new Set());
278
+ for (const binding of declared) {
279
+ bindings.add(binding);
280
+ scopes.get(scope).add(binding.name);
281
+ }
282
+ };
283
+ walk(ast, (node, parent) => {
284
+ if (node.type === "VariableDeclarator") declare(node.id, scopeFor(parent, parent.kind === "var"));
285
+ if (isFunction(node)) {
286
+ if (node.id) declare(node.id, node.type === "FunctionDeclaration" ? scopeFor(parent) : node);
287
+ node.params.forEach((parameter) => declare(parameter, node));
288
+ }
289
+ if (node.type === "ClassDeclaration" && node.id) declare(node.id, scopeFor(parent));
290
+ if (node.type === "CatchClause") declare(node.param, node);
291
+ if (node.type === "ImportDeclaration") node.specifiers.forEach((specifier) => declare(specifier.local, ast));
292
+ });
293
+ const scopeOf = (node) => {
294
+ let current = node;
295
+ while (current && !scopes.get(current)?.has(node.name)) current = parents.get(current);
296
+ return current;
297
+ };
298
+ walk(ast, (node, parent) => {
299
+ if (["ImportExpression", "ThisExpression", "MetaProperty", "ExportAllDeclaration"].includes(node.type)) eligible = false;
300
+ if (node.type === "ExportNamedDeclaration" && node.source) eligible = false;
301
+ if (node.type === "MemberExpression" && node.computed &&
302
+ (node.property.type !== "Literal" || PATH_STATE_NAMES.has(node.property.value))) eligible = false;
303
+ if (node.type === "Property") {
304
+ const name = node.key.type === "Identifier" && !node.computed ? node.key.name : node.key.value;
305
+ if (node.computed && node.key.type !== "Literal" ||
306
+ PATH_STATE_NAMES.has(name) || OPAQUE_RENDER_PROPS.has(name)) eligible = false;
307
+ }
308
+ if (node.type === "JSXSpreadAttribute") eligible = false;
309
+ if (node.type === "JSXAttribute") {
310
+ const name = attrName(node);
311
+ if (OPAQUE_RENDER_PROPS.has(name)) eligible = false;
312
+ if (/^on[A-Z]/.test(name)) {
313
+ const handler = node.value?.expression;
314
+ if (!isFunction(handler) || handler.params.length) eligible = false;
315
+ }
316
+ }
317
+ if (node.type === "Identifier") {
318
+ if (PATH_STATE_NAMES.has(node.name)) eligible = false;
319
+ if (reactBindings.has(node.name) &&
320
+ !["ImportDefaultSpecifier", "ImportNamespaceSpecifier"].includes(parent?.type) &&
321
+ !(parent?.type === "MemberExpression" && parent.object === node && !parent.computed &&
322
+ SINGLE_PAGE_REACT_APIS.has(parent.property.name))) eligible = false;
323
+ const property = parent?.type === "MemberExpression" && parent.property === node && !parent.computed ||
324
+ parent?.type === "Property" && parent.key === node && !parent.computed && !parent.shorthand;
325
+ const specifier = ["ImportSpecifier", "ExportSpecifier"].includes(parent?.type);
326
+ if (!property && !specifier && !bindings.has(node) &&
327
+ !scopeOf(node) && !GLOBAL_NAMES.has(node.name)) eligible = false;
328
+ if (routerBindings.has(node.name) && !specifier) eligible = false;
329
+ }
330
+ if (node.type === "JSXOpeningElement") {
331
+ const name = node.name;
332
+ if (name.type === "JSXMemberExpression") {
333
+ if (name.object.type !== "JSXIdentifier" || !reactBindings.has(name.object.name) ||
334
+ !["Fragment", "StrictMode"].includes(name.property.name)) eligible = false;
335
+ } else if (name.type !== "JSXIdentifier") eligible = false;
336
+ else if (OPAQUE_ELEMENTS.has(name.name.toLowerCase())) eligible = false;
337
+ else if (name.name === "BrowserRouter") {
338
+ if (!routerBindings.has(name.name) || node.attributes.length) eligible = false;
339
+ } else if (name.name === "Route" || name.name === "Routes" ||
340
+ NON_PATH_ROUTERS.has(name.name) || PATH_ROUTERS.has(name.name)) eligible = false;
341
+ else if (!/^[a-z]/.test(name.name) && (scopeOf(name) !== ast || !definesComponent(local(name.name)) &&
342
+ !["ImportSpecifier", "ImportDefaultSpecifier"].includes(local(name.name)?.type))) eligible = false;
343
+ }
344
+ });
345
+ return { eligible, app, imports, exports: exportNames, components };
346
+ }
347
+
79
348
  export function routeTableFromSource(src, filePath) {
80
349
  const ast = parseSource(src, filePath);
81
350
  if (!ast) return { patterns: [], routeCount: 0, complete: false, notFoundCatchAll: false };
@@ -1,35 +1,88 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { hasRouter } from "./has-router.mjs";
4
- import { routeTableFromSource } from "./extract-routes.mjs";
4
+ import { routeTableFromSource, singlePageFromSource } from "./extract-routes.mjs";
5
5
 
6
6
  const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", "public"]);
7
7
  const SOURCE_FILE_RE = /\.(?:tsx|jsx|ts|js|mjs)$/;
8
8
  const DECLARATION_FILE_RE = /\.d\.ts$/;
9
9
  const CATCH_ALL_PATTERN = "/*";
10
+ const TEMPLATE_HELPERS = ["components/routing/ScrollTopRouting.tsx", "components/seo/SEOHead.tsx"];
10
11
 
11
- async function sourceFiles(dir) {
12
- const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
12
+ async function sourceFiles(dir, scan) {
13
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => {
14
+ scan.readable = false;
15
+ return [];
16
+ });
13
17
  const nested = await Promise.all(
14
18
  entries.map(async (entry) => {
15
19
  const fullPath = path.join(dir, entry.name);
16
- if (entry.isDirectory()) return SKIP_DIRS.has(entry.name) ? [] : sourceFiles(fullPath);
20
+ if (SKIP_DIRS.has(entry.name)) return [];
21
+ if (entry.isDirectory()) return sourceFiles(fullPath, scan);
17
22
  const isSource = SOURCE_FILE_RE.test(entry.name) && !DECLARATION_FILE_RE.test(entry.name);
23
+ if (entry.isSymbolicLink()) scan.readable = false;
18
24
  return entry.isFile() && isSource ? [fullPath] : [];
19
25
  }),
20
26
  );
21
27
  return nested.flat();
22
28
  }
23
29
 
30
+ async function canonicalSources() {
31
+ const templates = new URL("../../templates/", import.meta.url);
32
+ const filemap = await fs.readFile(new URL("base/filemap.json", templates), "utf8").then(JSON.parse).catch(() => ({}));
33
+ const sources = await Promise.all([
34
+ ...Object.entries(filemap).map(([source, target]) => [`base/${source}`, target]),
35
+ ...TEMPLATE_HELPERS.map((target) => [`landing-page/${target}`, target]),
36
+ ]
37
+ .filter(([, target]) => SOURCE_FILE_RE.test(target))
38
+ .map(async ([source, target]) => [target, await fs.readFile(new URL(source, templates), "utf8").catch(() => null)]));
39
+ return new Map(sources);
40
+ }
41
+
42
+ async function importsResolved(files, sourceDir) {
43
+ const extensions = ["", ".tsx", ".jsx", ".ts", ".js", ".mjs", "/index.tsx", "/index.jsx", "/index.ts", "/index.js", "/index.mjs"];
44
+ for (const [file, analysis] of files) {
45
+ for (const { source, names, elements } of analysis.imports) {
46
+ if (!source.startsWith(".") && !source.startsWith("@/")) continue;
47
+ const base = source.startsWith("@/")
48
+ ? path.resolve(sourceDir, source.slice(2))
49
+ : path.resolve(path.dirname(file), source);
50
+ const relative = path.relative(sourceDir, base);
51
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return false;
52
+ const resolved = extensions.map((extension) => files.get(`${base}${extension}`)).find(Boolean);
53
+ if (resolved) {
54
+ if (names.some((name) => !resolved.exports.includes(name)) ||
55
+ elements.some((name) => !resolved.components.includes(name))) return false;
56
+ } else {
57
+ if (elements.length || !/\.(?:css|json|svg|png|jpe?g|gif|webp|woff2?)$/.test(base)) return false;
58
+ if (!await fs.stat(base).then((stat) => stat.isFile()).catch(() => false)) return false;
59
+ }
60
+ }
61
+ }
62
+ return true;
63
+ }
64
+
24
65
  export async function collectRouteTable(sourceDir) {
66
+ if (typeof sourceDir !== "string" || !sourceDir) {
67
+ return { routerFound: false, complete: false, hasCatchAll: false, patterns: ["/"] };
68
+ }
25
69
  let routerFound = false;
26
70
  let complete = true;
27
71
  let routeCount = 0;
28
72
  let notFoundCatchAll = false;
29
73
  const declared = new Set();
74
+ const scan = { readable: true };
75
+ const sources = new Map();
76
+ const canonical = await canonicalSources();
77
+ sourceDir = path.resolve(sourceDir);
30
78
 
31
- for (const file of (await sourceFiles(sourceDir)).sort()) {
32
- const src = await fs.readFile(file, "utf8").catch(() => "");
79
+ for (const file of (await sourceFiles(sourceDir, scan)).sort()) {
80
+ const src = await fs.readFile(file, "utf8").catch(() => {
81
+ scan.readable = false;
82
+ return "";
83
+ });
84
+ const trusted = canonical.get(path.relative(sourceDir, file).split(path.sep).join("/")) === src;
85
+ sources.set(file, singlePageFromSource(src, file, trusted));
33
86
  if (!hasRouter(src)) continue;
34
87
  routerFound = true;
35
88
 
@@ -40,9 +93,14 @@ export async function collectRouteTable(sourceDir) {
40
93
  for (const pattern of table.patterns) declared.add(pattern);
41
94
  }
42
95
 
96
+ const apps = [...sources].filter(([file]) => /^App\.(?:tsx|jsx|ts|js|mjs)$/.test(path.basename(file)));
97
+ const rootOnly = scan.readable && sources.size > 0 && routeCount === 0 &&
98
+ apps.length === 1 && apps[0][1].app && [...sources.values()].every((source) => source.eligible) &&
99
+ await importsResolved(sources, sourceDir);
100
+
43
101
  return {
44
102
  routerFound,
45
- complete: routerFound && complete && routeCount > 0 && notFoundCatchAll,
103
+ complete: (routerFound && complete && routeCount > 0 && notFoundCatchAll) || rootOnly,
46
104
  hasCatchAll: declared.has(CATCH_ALL_PATTERN),
47
105
  patterns: [...new Set(["/", ...declared])]
48
106
  .filter((pattern) => pattern !== CATCH_ALL_PATTERN)
@@ -1,4 +1,5 @@
1
- import { test } from "node:test";
1
+ import { test, mock } from "node:test";
2
+ import fsPromises from "node:fs/promises";
2
3
  import assert from "node:assert/strict";
3
4
  import fs from "node:fs";
4
5
  import os from "node:os";
@@ -73,7 +74,7 @@ test("routes declared in a second router file are part of the table", async () =
73
74
  assert.deepEqual(table.patterns, ["/", "/blog/:slug"]);
74
75
  });
75
76
 
76
- test("a single-page site with only a router import gets an incomplete table with its home", async () => {
77
+ test("a single-page site with an incidental null-rendering location helper is complete", async () => {
77
78
  const table = await collectRouteTable(
78
79
  siteWith({
79
80
  "App.tsx": `export default function App() { return <main />; }`,
@@ -81,13 +82,13 @@ test("a single-page site with only a router import gets an incomplete table with
81
82
  }),
82
83
  );
83
84
 
84
- assert.deepEqual(table, { routerFound: true, complete: false, hasCatchAll: false, patterns: ["/"] });
85
+ assert.deepEqual(table, { routerFound: true, complete: true, hasCatchAll: false, patterns: ["/"] });
85
86
  });
86
87
 
87
- test("a site without any router has no table", async () => {
88
+ test("a plain default-exported App is complete without claiming a router or catch-all", async () => {
88
89
  const table = await collectRouteTable(siteWith({ "App.tsx": `export default () => <main />;` }));
89
90
 
90
- assert.equal(table.routerFound, false);
91
+ assert.deepEqual(table, { routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
91
92
  });
92
93
 
93
94
  test("splat routes are never prerendered as a page", async () => {
@@ -186,3 +187,359 @@ test("a router file that does not parse makes the table incomplete", async () =>
186
187
 
187
188
  assert.equal(table.complete, false);
188
189
  });
190
+
191
+ const SINGLE_PAGE = `export default function App() { return <main>Home</main>; }`;
192
+
193
+ for (const source of [
194
+ SINGLE_PAGE,
195
+ `const App = () => <main />; export default App;`,
196
+ `import { BrowserRouter } from "react-router-dom"; export default () => <BrowserRouter><main /></BrowserRouter>;`,
197
+ ]) {
198
+ test(`a supported root-only App is complete: ${source}`, async () => {
199
+ const table = await collectRouteTable(siteWith({ "App.tsx": source }));
200
+ assert.deepEqual(table, {
201
+ routerFound: source.includes("react-router-dom"),
202
+ complete: true,
203
+ hasCatchAll: false,
204
+ patterns: ["/"],
205
+ });
206
+ });
207
+ }
208
+
209
+ test("a resolved local component and a null scroll helper remain root-only", async () => {
210
+ const table = await collectRouteTable(siteWith({
211
+ "App.tsx": `import Home from "./Home"; export default () => <Home />;`,
212
+ "Home.tsx": `export default () => <main>Home</main>;`,
213
+ "Scroll.tsx": `import { useEffect } from "react";
214
+ import { useLocation } from "react-router-dom";
215
+ export default function Scroll() {
216
+ const { pathname } = useLocation();
217
+ useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
218
+ return null;
219
+ }`,
220
+ }));
221
+ assert.equal(table.complete, true);
222
+ });
223
+
224
+ test("Helmet title, meta and link props remain root-only", async () => {
225
+ const table = await collectRouteTable(siteWith({
226
+ "App.tsx": `import { Helmet, HelmetProvider } from "react-helmet-async";
227
+ export default function App() {
228
+ return <HelmetProvider><main><Helmet title="Home" meta={[{ name: "description", content: "Welcome" }]} link={[{ rel: "canonical", href: "https://one-page.example/" }]} /><h1>Home</h1></main></HelmetProvider>;
229
+ }`,
230
+ }));
231
+ assert.deepEqual(table, { routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
232
+ });
233
+
234
+ test("a public dispatcher loaded through Helmet cannot certify a root-only table", async (t) => {
235
+ const dir = siteWith({
236
+ "App.tsx": `import { Helmet } from "react-helmet-async";
237
+ export default function App() {
238
+ return <main><Helmet script={[{ src: "/dispatcher.js", async: true }]} /><h1>Home</h1></main>;
239
+ }`,
240
+ "public/dispatcher.js": `if (location.pathname === "/about") document.querySelector("main").innerHTML = "<h1>About</h1>";`,
241
+ });
242
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
243
+
244
+ assert.deepEqual(await collectRouteTable(dir), {
245
+ routerFound: false, complete: false, hasCatchAll: false, patterns: ["/"],
246
+ });
247
+ });
248
+
249
+ test("Helmet title and meta children with imported components remain root-only", async () => {
250
+ const table = await collectRouteTable(siteWith({
251
+ "App.tsx": `import { Helmet } from "react-helmet-async";
252
+ import { Hero } from "./Hero";
253
+ import Footer from "./Footer";
254
+ export default function App() {
255
+ return <main><Helmet><title>Home</title><meta name="description" content="Welcome" /></Helmet><Hero /><Footer /></main>;
256
+ }`,
257
+ "Hero.tsx": `export function Hero() { return <h1>Home</h1>; }`,
258
+ "Footer.tsx": `const Footer = () => <footer />; export default Footer;`,
259
+ }));
260
+ assert.deepEqual(table, { routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
261
+ });
262
+
263
+ for (const [label, files] of [
264
+ ["named string", {
265
+ "App.tsx": `import { Helmet } from "react-helmet-async"; import { Body } from "./tags"; export default () => <main><Helmet><Body onload='if (location.pathname === "/about") document.body.textContent = "About";' /></Helmet></main>;`,
266
+ "tags.ts": `export const Body = "body";`,
267
+ }],
268
+ ["default string", {
269
+ "App.tsx": `import Script from "./Script"; export default () => <main><Script async src="/dispatcher.js" /></main>;`,
270
+ "Script.ts": `export default "script";`,
271
+ }],
272
+ ["reassigned export", {
273
+ "App.tsx": `import { Page } from "./Page"; export default () => <main><Page async src="/dispatcher.js" /></main>;`,
274
+ "Page.tsx": `export let Page = () => <main />; export const swap = () => { Page = "script"; };`,
275
+ }],
276
+ ["JSON value", {
277
+ "App.tsx": `import Script from "./tag.json"; export default () => <main><Script async src="/dispatcher.js" /></main>;`,
278
+ "tag.json": `"script"`,
279
+ }],
280
+ ]) {
281
+ test(`an imported non-component element type cannot certify a root-only table: ${label}`, async () => {
282
+ assert.equal((await collectRouteTable(siteWith(files))).complete, false);
283
+ });
284
+ }
285
+
286
+ const UNSUPPORTED_SINGLE_PAGES = [
287
+ ["empty Routes", `import { Routes } from "react-router-dom"; export default () => <Routes />;`],
288
+ ["aliased Routes", `import { Routes as R } from "react-router-dom"; export default () => <R />;`],
289
+ ["namespace router", `import * as RR from "react-router-dom"; export default () => <RR.BrowserRouter><main /></RR.BrowserRouter>;`],
290
+ ["aliased BrowserRouter", `import { BrowserRouter as B } from "react-router-dom"; export default () => <B><main /></B>;`],
291
+ ["indirect BrowserRouter", `import { BrowserRouter } from "react-router-dom"; const B = BrowserRouter; export default () => <B><main /></B>;`],
292
+ ["computed router", `import * as RR from "react-router-dom"; const B = RR["BrowserRouter"]; export default () => <B />;`],
293
+ ["useRoutes", `import { useRoutes } from "react-router-dom"; export default function App() { return useRoutes([]); }`],
294
+ ["aliased data router", `import { createBrowserRouter as make } from "react-router-dom"; const r = make([]); export default () => <main />;`],
295
+ ["wouter", `import { Router } from "wouter"; export default () => <Router><main /></Router>;`],
296
+ ["tanstack", `import { RouterProvider } from "@tanstack/react-router"; export default () => <RouterProvider />;`],
297
+ ["unknown external dispatcher", `import Page from "custom-pages"; export default () => <Page />;`],
298
+ ["basename", `import { BrowserRouter } from "react-router-dom"; export default () => <BrowserRouter basename="/shop"><main /></BrowserRouter>;`],
299
+ ["spread router", `import { BrowserRouter } from "react-router-dom"; const config = {}; export default () => <BrowserRouter {...config}><main /></BrowserRouter>;`],
300
+ ["pathname rendering", `export default () => window.location.pathname === "/" ? <main /> : <aside />;`],
301
+ ["DOM ref URL rendering", `import { useState } from "react"; export default function App() { const [page, setPage] = useState("Home"); return <main ref={node => { if (node) setPage(node.baseURI.endsWith("/about") ? "About" : "Home"); }}>{page}</main>; }`],
302
+ ["computed DOM ref URL rendering", `import { useState } from "react"; export default function App() { const [page, setPage] = useState("Home"); return <main ref={node => { if (node) setPage(node["base" + "URI"].endsWith("/about") ? "About" : "Home"); }}>{page}</main>; }`],
303
+ ["DOM event URL rendering", `import { useState } from "react"; export default function App() { const [page, setPage] = useState("Home"); return <main onClick={event => setPage(event.target.baseURI.endsWith("/about") ? "About" : "Home")}>{page}</main>; }`],
304
+ ["computed DOM event URL rendering", `import { useState } from "react"; export default function App() { const [page, setPage] = useState("Home"); return <main onClick={event => setPage(event.target["base" + "URI"].endsWith("/about") ? "About" : "Home")}>{page}</main>; }`],
305
+ ["spread DOM callbacks", `import { useState } from "react"; export default function App() { const [page, setPage] = useState("Home"); const props = { ref: node => node && setPage(node.baseURI) }; return <main {...props}>{page}</main>; }`],
306
+ ["opaque HTML", `export default () => <main dangerouslySetInnerHTML={{ __html: html }} />; const html = "<img onerror='alert(location.pathname)'>";`],
307
+ ["opaque script", `export default () => <main><script src="/dispatcher.js" /></main>;`],
308
+ ["aliased Helmet script", `import { Helmet as Head } from "react-helmet-async"; export default () => <main><Head script={[{ src: "/dispatcher.js", async: true }]} /></main>;`],
309
+ ["inline Helmet script", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet script={[{ innerHTML: 'if (location.pathname === "/about") document.body.textContent = "About";' }]} /></main>;`],
310
+ ["Helmet noscript HTML", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet noscript={[{ innerHTML: '<img src="/missing" onerror="alert(location.pathname)">' }]} /></main>;`],
311
+ ["Helmet body event attributes", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet bodyAttributes={{ onload: 'if (location.pathname === "/about") document.body.textContent = "About";' }} /></main>;`],
312
+ ["Helmet HTML event attributes", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet htmlAttributes={{ onload: 'if (location.pathname === "/about") document.body.textContent = "About";' }} /></main>;`],
313
+ ["Helmet body event child", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet><body onload='if (location.pathname === "/about") document.body.textContent = "About";' /></Helmet></main>;`],
314
+ ["Helmet HTML event child", `import { Helmet } from "react-helmet-async"; export default () => <main><Helmet><html onclick='if (location.pathname === "/about") document.body.textContent = "About";' /></Helmet></main>;`],
315
+ ["mixed-case opaque element", `export default () => <main><sCript src="/dispatcher.js" /></main>;`],
316
+ ["string element alias", `import { Helmet } from "react-helmet-async"; const Body = "body"; export default () => <main><Helmet><Body onload='if (location.pathname === "/about") document.body.textContent = "About";' /></Helmet></main>;`],
317
+ ["lowercase-free element alias", `const _script = "script"; export default () => <main><_script async src="/dispatcher.js" /></main>;`],
318
+ ["reassigned component", `let Page = () => <main />; Page = "script"; export default () => <main><Page async src="/dispatcher.js" /></main>;`],
319
+ ["shadowed component", `const Page = () => <main />; export default function App() { const Page = "script"; return <main><Page async src="/dispatcher.js" /></main>; }`],
320
+ ["quoted element-constructor destructuring", `import React, { useState } from "react"; const { "createElement": h } = React; export default function App() { const [page, setPage] = useState("Home"); return <main>{h("div", { ref: node => { if (node) setPage(node.baseURI.endsWith("/about") ? "About" : "Home"); } }, page)}</main>; }`],
321
+ ["computed element-constructor destructuring", `import React, { useState } from "react"; const key = "createElement"; const { [key]: h } = React; export default function App() { const [page, setPage] = useState("Home"); return <main>{h("div", { ref: node => node && setPage(node.baseURI) }, page)}</main>; }`],
322
+ ["reflected element constructor", `import React from "react"; const h = Object.getOwnPropertyDescriptor(React, "createElement").value; export default () => <main>{h("script", { src: "/dispatcher.js" })}</main>;`],
323
+ ["indirect element construction", `import { createElement as element } from "react"; export default () => <main>{element("div", { ref: node => node && node.baseURI })}</main>;`],
324
+ ["computed location", `const p = globalThis["location"]["pathname"]; export default () => <main>{p}</main>;`],
325
+ ["location destructuring", `const { location: current } = window; export default () => <main>{current.pathname}</main>;`],
326
+ ["rendering location hook", `import { useLocation } from "react-router-dom"; export default function App() { const p = useLocation(); return <main>{p.pathname}</main>; }`],
327
+ ["dynamic import", `import { lazy } from "react"; const Page = lazy(() => import("./Page")); export default () => <Page />;`],
328
+ ["require", `const Page = require("./Page"); export default () => <Page />;`],
329
+ ["unresolved default", `export default App;`],
330
+ ["unresolved JSX", `export default () => <Home />;`],
331
+ ["unresolved expression", `export default () => <main>{missing}</main>;`],
332
+ ["binding in a different scope", `function helper() { const missing = "other"; return missing; } export default () => <main>{missing}</main>;`],
333
+ ["aliased location hook", `import { useLocation as loc } from "react-router-dom"; export default function App() { const p = loc(); return <main>{p.pathname}</main>; }`],
334
+ ["null helper with a rendering effect", `import { useEffect } from "react"; import { useLocation } from "react-router-dom"; export default function App() { const { pathname } = useLocation(); useEffect(() => { document.body.innerHTML = pathname; }, [pathname]); return null; }`],
335
+ ["unresolved import", `import Home from "./missing"; export default () => <Home />;`],
336
+ ["unresolved named import", `import { Home } from "./Home"; export default () => <Home />;`],
337
+ ["non-rendering App", `export default function App() { return null; }`],
338
+ ["empty App", ``],
339
+ ["unparseable App", `export default () => <main;`],
340
+ ];
341
+
342
+ for (const [label, source] of UNSUPPORTED_SINGLE_PAGES) {
343
+ test(`unsupported single-page analysis fails open: ${label}`, async () => {
344
+ const table = await collectRouteTable(siteWith({
345
+ "App.tsx": source,
346
+ "Home.tsx": `export const Other = () => <main />;`,
347
+ "Page.tsx": `export default () => <main />;`,
348
+ }));
349
+ assert.equal(table.complete, false);
350
+ });
351
+ }
352
+
353
+ test("missing source and files without an App cannot prove root-only completeness", async () => {
354
+ const dir = siteWith({});
355
+ for (const source of [undefined, null, "", dir, path.join(dir, "missing"), siteWith({ "Home.tsx": SINGLE_PAGE })]) {
356
+ assert.equal((await collectRouteTable(source)).complete, false);
357
+ }
358
+ });
359
+
360
+ for (const file of ["A.tsx", "Z.tsx", "lib/remixer/custom.tsx", "lib/custom.tsx"]) {
361
+ test(`an unsupported second application source vetoes root-only completeness: ${file}`, async () => {
362
+ for (const source of [
363
+ `import { useRoutes as routes } from "react-router-dom"; export const page = routes([]);`,
364
+ `export const Page = () => window.location.pathname === "/other" ? <aside /> : <main />;`,
365
+ `export const broken = <main;`,
366
+ ]) {
367
+ const table = await collectRouteTable(siteWith({ "App.tsx": SINGLE_PAGE, [file]: source }));
368
+ assert.equal(table.complete, false);
369
+ }
370
+ });
371
+ }
372
+
373
+ test("an unreadable application source cannot prove root-only completeness", async () => {
374
+ const dir = siteWith({ "App.tsx": SINGLE_PAGE, "Other.tsx": `export const other = 1;` });
375
+ const readFile = fsPromises.readFile.bind(fsPromises);
376
+ const mocked = mock.method(fsPromises, "readFile", async (file, ...args) => {
377
+ if (file === path.join(dir, "Other.tsx")) throw new Error("unreadable fixture");
378
+ return readFile(file, ...args);
379
+ });
380
+ try {
381
+ assert.equal((await collectRouteTable(dir)).complete, false);
382
+ } finally {
383
+ mocked.mock.restore();
384
+ }
385
+ });
386
+
387
+ const canonicalScaffold = () => {
388
+ const base = new URL("../../templates/base/", import.meta.url);
389
+ const filemap = JSON.parse(fs.readFileSync(new URL("filemap.json", base), "utf8"));
390
+ return {
391
+ ...Object.fromEntries(Object.entries(filemap)
392
+ .filter(([, target]) => /\.(?:ts|tsx)$/.test(target))
393
+ .map(([source, target]) => [target, fs.readFileSync(new URL(source, base), "utf8")])),
394
+ "App.tsx": SINGLE_PAGE,
395
+ "tailwind.config.ts": `export default {};`,
396
+ "fonts.ts": `export const GOOGLE_FONTS_URL = "https://fonts.googleapis.com/css2?family=Inter";`,
397
+ "index.css": "",
398
+ };
399
+ };
400
+
401
+ test("the byte-identical filemapped SDK bootstrap and SEO observer do not veto a single page", async () => {
402
+ const table = await collectRouteTable(siteWith(canonicalScaffold()));
403
+ assert.deepEqual(table, { routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
404
+ });
405
+
406
+ test("a modified canonical SEO helper that dispatches page content vetoes a single page", async () => {
407
+ const files = canonicalScaffold();
408
+ files["lib/remixer/seo.tsx"] = `export const RemixerSeo = () => window.location.pathname === "/other" ? <aside /> : <main />;`;
409
+ assert.equal((await collectRouteTable(siteWith(files))).complete, false);
410
+ });
411
+
412
+ test("a canonical filename or directory alone does not exempt a dispatcher", async () => {
413
+ for (const file of ["index.tsx", "lib/remixer/seo.tsx", "lib/remixer/custom.tsx", "lib/custom.tsx"]) {
414
+ const files = canonicalScaffold();
415
+ files[file] = `export const Dispatch = () => location.pathname === "/other" ? <aside /> : <main />;`;
416
+ assert.equal((await collectRouteTable(siteWith(files))).complete, false);
417
+ }
418
+ });
419
+
420
+ const LANDING_PAGE_HELPERS = ["components/routing/ScrollTopRouting.tsx", "components/seo/SEOHead.tsx"];
421
+ const LANDING_PAGE_APP = `import { BrowserRouter } from "react-router-dom";
422
+ import ScrollTopRouting from "@/components/routing/ScrollTopRouting";
423
+ import Home from "@/pages/Home";
424
+
425
+ export default function App() {
426
+ return (
427
+ <BrowserRouter>
428
+ <ScrollTopRouting />
429
+ <Home />
430
+ </BrowserRouter>
431
+ );
432
+ }`;
433
+ const DISPATCHER = `\nexport const Dispatch = () => location.pathname === "/other" ? <aside /> : <main />;\n`;
434
+
435
+ const landingPageScaffold = (app = LANDING_PAGE_APP) => ({
436
+ ...canonicalScaffold(),
437
+ ...Object.fromEntries(LANDING_PAGE_HELPERS.map((file) =>
438
+ [file, fs.readFileSync(new URL(`../../templates/landing-page/${file}`, import.meta.url), "utf8")])),
439
+ "App.tsx": app,
440
+ "pages/Home.tsx": `import SEOHead from "@/components/seo/SEOHead";
441
+
442
+ export default function Home() {
443
+ return (
444
+ <>
445
+ <SEOHead titleKey="Home" descriptionKey="Welcome to our one-page site." />
446
+ <main><h1>Home</h1></main>
447
+ </>
448
+ );
449
+ }`,
450
+ "assets/images.json": "{}\n",
451
+ });
452
+
453
+ for (const [label, app] of [
454
+ ["a BrowserRouter App rendering Home", LANDING_PAGE_APP],
455
+ ["the bare template App", `import React from "react";\n\nconst App: React.FC = () => {\n return <main />;\n};\n\nexport default App;\n`],
456
+ ]) {
457
+ test(`the byte-identical landing-page scroll and SEO helpers do not veto a single page: ${label}`, async () => {
458
+ const table = await collectRouteTable(siteWith(landingPageScaffold(app)));
459
+ assert.deepEqual(table, { routerFound: true, complete: true, hasCatchAll: false, patterns: ["/"] });
460
+ });
461
+ }
462
+
463
+ for (const [label, edit] of [
464
+ ...LANDING_PAGE_HELPERS.map((file) => [`modified ${file}`, (files) => { files[file] += DISPATCHER; }]),
465
+ ["SEO helper bytes at another path", (files) => {
466
+ files["components/seo/Head.tsx"] = files["components/seo/SEOHead.tsx"];
467
+ delete files["components/seo/SEOHead.tsx"];
468
+ files["pages/Home.tsx"] = files["pages/Home.tsx"].replace("components/seo/SEOHead", "components/seo/Head");
469
+ }],
470
+ ["SEO helper without its images manifest", (files) => { delete files["assets/images.json"]; }],
471
+ ]) {
472
+ test(`landing-page helpers are trusted only as the canonical file at the canonical path: ${label}`, async () => {
473
+ const files = landingPageScaffold();
474
+ edit(files);
475
+ assert.equal((await collectRouteTable(siteWith(files))).complete, false);
476
+ });
477
+ }
478
+
479
+ test("an unreadable source directory cannot prove root-only completeness", async () => {
480
+ const dir = siteWith({ "App.tsx": SINGLE_PAGE, "components/Other.tsx": `export const other = 1;` });
481
+ const readdir = fsPromises.readdir.bind(fsPromises);
482
+ const mocked = mock.method(fsPromises, "readdir", async (file, ...args) => {
483
+ if (file === path.join(dir, "components")) throw new Error("unreadable fixture");
484
+ return readdir(file, ...args);
485
+ });
486
+ try {
487
+ assert.equal((await collectRouteTable(dir)).complete, false);
488
+ } finally {
489
+ mocked.mock.restore();
490
+ }
491
+ });
492
+
493
+ test("a split-file data router vetoes a plain App even when its API is aliased", async () => {
494
+ const table = await collectRouteTable(siteWith({
495
+ "App.tsx": SINGLE_PAGE,
496
+ "router.ts": `import { createBrowserRouter as create } from "react-router-dom"; export const router = create([{ path: "/other" }]);`,
497
+ }));
498
+ assert.equal(table.complete, false);
499
+ });
500
+
501
+ test("a literal JSX route cannot become complete through the root-only branch", async () => {
502
+ const table = await collectRouteTable(siteWith({
503
+ "App.tsx": `import { BrowserRouter } from "react-router-dom"; export default () => <BrowserRouter><main /></BrowserRouter>;`,
504
+ "routes.tsx": `import { Route } from "react-router-dom"; export const Other = () => <Route path="/other" element={<aside />} />;`,
505
+ }));
506
+ assert.equal(table.complete, false);
507
+ assert.deepEqual(table.patterns, ["/", "/other"]);
508
+ });
509
+
510
+ test("a null arrow location helper fails open without throwing", async () => {
511
+ const table = await collectRouteTable(siteWith({
512
+ "App.tsx": SINGLE_PAGE,
513
+ "Helper.jsx": `import { useLocation } from "react-router-dom"; export default () => null;`,
514
+ }));
515
+ assert.equal(table.complete, false);
516
+ });
517
+
518
+ test("a zero-argument local-state event handler remains path-independent", async () => {
519
+ const table = await collectRouteTable(siteWith({
520
+ "App.tsx": `import { useState } from "react";
521
+ export default function App() {
522
+ const [open, setOpen] = useState(false);
523
+ return <main><button onClick={() => setOpen(!open)}>Menu</button>{open ? <nav>Contact</nav> : null}</main>;
524
+ }`,
525
+ }));
526
+ assert.deepEqual(table, { routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
527
+ });
528
+
529
+ test("ignored dependency directories remain ignored when symlinked", async () => {
530
+ const dir = siteWith({ "App.tsx": SINGLE_PAGE });
531
+ const dependency = siteWith({ "router.tsx": `export const Page = () => location.pathname;` });
532
+ fs.symlinkSync(dependency, path.join(dir, "node_modules"), "dir");
533
+ assert.equal((await collectRouteTable(dir)).complete, true);
534
+ });
535
+
536
+ test("direct React namespace hooks and fragments remain supported", async () => {
537
+ const table = await collectRouteTable(siteWith({
538
+ "App.tsx": `import React from "react";
539
+ export default function App() {
540
+ const [open, setOpen] = React.useState(false);
541
+ return <React.Fragment><button onClick={() => setOpen(!open)}>Menu</button>{open ? <nav>Contact</nav> : null}</React.Fragment>;
542
+ }`,
543
+ }));
544
+ assert.equal(table.complete, true);
545
+ });
@@ -92,7 +92,7 @@ export async function writeIndexNowKeyFile(settings = SEO_SETTINGS) {
92
92
  }
93
93
 
94
94
  export async function writeRoutesJson(routeTable) {
95
- if (routeTable?.routerFound) {
95
+ if (routeTable?.routerFound || routeTable?.complete === true) {
96
96
  await writeJsonFile("_routes.json", {
97
97
  complete: routeTable.complete === true,
98
98
  patterns: routeTable.patterns,
@@ -17,6 +17,7 @@ const {
17
17
  writeIndexNowKeyFile,
18
18
  writeRedirectsJson,
19
19
  writeRoutesJson,
20
+ writeSiteFiles,
20
21
  } = await import("./site-files.mjs");
21
22
 
22
23
  const { createRunClock } = await import("./run-clock.mjs");
@@ -52,15 +53,47 @@ test("_redirects.json is a bare array and is not written without redirects", asy
52
53
  assert.deepEqual(JSON.parse(readBuild("_redirects.json")), [{ from: "/drinks", to: "/menu" }]);
53
54
  });
54
55
 
55
- test("_routes.json carries the declared patterns only when a router was found", async () => {
56
- await writeRoutesJson({ routerFound: false, hasCatchAll: false, patterns: ["/"] });
57
- assert.equal(existsInBuild("_routes.json"), false);
56
+ test("_routes.json carries declared router patterns and serializes completeness as strict true only", async () => {
57
+ const patterns = ["/", "/about", "/product/:id", "/blog/*"];
58
+ for (const complete of [true, false, undefined, "true", 1, [], {}]) {
59
+ await writeRoutesJson({ routerFound: true, complete, hasCatchAll: true, patterns });
60
+ assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: complete === true, patterns });
61
+ }
62
+ });
63
+
64
+ test("_routes.json is written for a complete root-only table without a router", async () => {
65
+ fs.rmSync(path.join(BUILD_DIR, "_routes.json"), { force: true });
58
66
 
59
- await writeRoutesJson({ routerFound: true, complete: true, hasCatchAll: true, patterns: ["/", "/about", "/product/:id", "/blog/*"] });
60
- assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: true, patterns: ["/", "/about", "/product/:id", "/blog/*"] });
67
+ await writeRoutesJson({ routerFound: false, complete: true, hasCatchAll: false, patterns: ["/"] });
61
68
 
62
- await writeRoutesJson({ routerFound: true, complete: false, hasCatchAll: false, patterns: ["/"] });
63
- assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: false, patterns: ["/"] });
69
+ assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: true, patterns: ["/"] });
70
+ });
71
+
72
+ test("_routes.json skips absent, unknown and incomplete tables without a router", async () => {
73
+ for (const routeTable of [
74
+ undefined,
75
+ null,
76
+ {},
77
+ { patterns: ["/"] },
78
+ { routerFound: false, hasCatchAll: false, patterns: ["/"] },
79
+ { routerFound: false, complete: false, hasCatchAll: false, patterns: ["/"] },
80
+ ]) {
81
+ fs.rmSync(path.join(BUILD_DIR, "_routes.json"), { force: true });
82
+
83
+ await writeRoutesJson(routeTable);
84
+
85
+ assert.equal(existsInBuild("_routes.json"), false);
86
+ }
87
+ });
88
+
89
+ test("truthy non-boolean completeness never authorizes a manifest without a router", async () => {
90
+ for (const complete of ["true", 1, [], {}]) {
91
+ fs.rmSync(path.join(BUILD_DIR, "_routes.json"), { force: true });
92
+
93
+ await writeRoutesJson({ routerFound: false, complete, hasCatchAll: false, patterns: ["/"] });
94
+
95
+ assert.equal(existsInBuild("_routes.json"), false);
96
+ }
64
97
  });
65
98
 
66
99
  test("without a prerendered not-found page a plain noindex page is written at the root", async () => {
@@ -167,3 +200,179 @@ test("without a catch-all the plain page is written and nothing is prerendered",
167
200
  assert.deepEqual(rendered, []);
168
201
  assert.match(readBuild("404.html"), /<h1>Page not found<\/h1>/);
169
202
  });
203
+
204
+ const ROOT_ONLY_HOME = `<!doctype html><html lang="en"><head><title>Home</title>
205
+ <script type="module" src="/assets/home.js"></script></head>
206
+ <body><div id="root"><main><h1>Home</h1><p>Welcome to our one-page site.</p><a href="#contact">Contact</a></main></div></body></html>`;
207
+ const ROOT_ONLY_APP = `export default function App() {
208
+ return <main><h1>Home</h1><p>Welcome to our one-page site.</p><a href="#contact">Contact</a></main>;
209
+ }`;
210
+
211
+ function landingPageSources() {
212
+ const templates = new URL("../../templates/", import.meta.url);
213
+ const read = (file) => fs.readFileSync(new URL(file, templates), "utf8");
214
+ const filemap = JSON.parse(read("base/filemap.json"));
215
+ return {
216
+ ...Object.fromEntries(Object.entries(filemap)
217
+ .filter(([, target]) => /\.(?:ts|tsx)$/.test(target))
218
+ .map(([source, target]) => [target, read(`base/${source}`)])),
219
+ "components/routing/ScrollTopRouting.tsx": read("landing-page/components/routing/ScrollTopRouting.tsx"),
220
+ "components/seo/SEOHead.tsx": read("landing-page/components/seo/SEOHead.tsx"),
221
+ "App.tsx": `import { BrowserRouter } from "react-router-dom";
222
+ import ScrollTopRouting from "@/components/routing/ScrollTopRouting";
223
+ import Home from "@/pages/Home";
224
+
225
+ export default function App() {
226
+ return (
227
+ <BrowserRouter>
228
+ <ScrollTopRouting />
229
+ <Home />
230
+ </BrowserRouter>
231
+ );
232
+ }`,
233
+ "pages/Home.tsx": `import SEOHead from "@/components/seo/SEOHead";
234
+
235
+ export default function Home() {
236
+ return (
237
+ <>
238
+ <SEOHead titleKey="Home" descriptionKey="Welcome to our one-page site." />
239
+ <main><h1>Home</h1><p>Welcome to our one-page site.</p><a href="/#contact">Contact</a></main>
240
+ </>
241
+ );
242
+ }`,
243
+ "assets/images.json": "{}\n",
244
+ "tailwind.config.ts": `export default {};`,
245
+ "fonts.ts": `export const GOOGLE_FONTS_URL = "https://fonts.googleapis.com/css2?family=Inter";`,
246
+ "index.css": "",
247
+ };
248
+ }
249
+
250
+ const ROOT_ONLY_SOURCES = [
251
+ {
252
+ name: "a plain default App",
253
+ files: { "App.tsx": ROOT_ONLY_APP },
254
+ routerFound: false,
255
+ },
256
+ {
257
+ name: "an incidental null location helper",
258
+ files: {
259
+ "App.tsx": `import { BrowserRouter } from "react-router-dom";
260
+ import ScrollTopRouting from "./components/ScrollTopRouting";
261
+ export default function App() {
262
+ return <BrowserRouter><ScrollTopRouting /><main><h1>Home</h1><p>Welcome to our one-page site.</p><a href="#contact">Contact</a></main></BrowserRouter>;
263
+ }`,
264
+ "components/ScrollTopRouting.tsx": `import { useEffect } from "react";
265
+ import { useLocation } from "react-router-dom";
266
+ export default function ScrollTopRouting() {
267
+ const { pathname } = useLocation();
268
+ useEffect(() => { window.scrollTo(0, 0); }, [pathname]);
269
+ return null;
270
+ }`,
271
+ },
272
+ routerFound: true,
273
+ },
274
+ {
275
+ name: "a BrowserRouter wrapper without route declarations",
276
+ files: {
277
+ "App.tsx": `import { BrowserRouter } from "react-router-dom";
278
+ export default function App() {
279
+ return <BrowserRouter><main><h1>Home</h1><p>Welcome to our one-page site.</p><a href="#contact">Contact</a></main></BrowserRouter>;
280
+ }`,
281
+ },
282
+ routerFound: true,
283
+ },
284
+ {
285
+ name: "the default landing-page template with its scroll and SEO helpers",
286
+ files: landingPageSources(),
287
+ routerFound: true,
288
+ },
289
+ ];
290
+
291
+ async function writeCollectedSiteFiles(t, files, settings = SWITCHED_ON) {
292
+ const { collectRouteTable } = await import("./route-patterns.mjs");
293
+ const sourceDir = fs.mkdtempSync(path.join(os.tmpdir(), "site-files-source-"));
294
+ t.after(() => fs.rmSync(sourceDir, { recursive: true, force: true }));
295
+ for (const [file, content] of Object.entries(files)) {
296
+ const target = path.join(sourceDir, file);
297
+ fs.mkdirSync(path.dirname(target), { recursive: true });
298
+ fs.writeFileSync(target, content, "utf8");
299
+ }
300
+ fs.rmSync(BUILD_DIR, { recursive: true, force: true });
301
+ fs.mkdirSync(BUILD_DIR, { recursive: true });
302
+ fs.writeFileSync(path.join(BUILD_DIR, "index.html"), ROOT_ONLY_HOME, "utf8");
303
+
304
+ const routeTable = await collectRouteTable(sourceDir);
305
+ const rendered = [];
306
+ await writeSiteFiles({ sitemapRoutes: ["/"], routeTable }, settings);
307
+ await writeNotFoundPageInTime(
308
+ { routeTable, prerender: async (route) => (rendered.push(route), ROOT_ONLY_HOME), clock: clockAt(0) },
309
+ settings,
310
+ );
311
+ return { routeTable, rendered };
312
+ }
313
+
314
+ for (const { name, files, routerFound } of ROOT_ONLY_SOURCES) {
315
+ test(`collector-to-artifacts: ${name} gets a complete root manifest and a plain 404 without changing Home`, async (t) => {
316
+ const { routeTable, rendered } = await writeCollectedSiteFiles(t, files);
317
+
318
+ assert.deepEqual(routeTable, { routerFound, complete: true, hasCatchAll: false, patterns: ["/"] });
319
+ assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: true, patterns: ["/"] });
320
+ assert.match(readBuild("404.html"), /<meta name="robots" content="noindex" \/>/);
321
+ assert.match(readBuild("404.html"), /<title>Page not found \| Home<\/title>/);
322
+ assert.match(readBuild("404.html"), /<h1>Page not found<\/h1>/);
323
+ assert.match(readBuild("404.html"), /<a href="\/">Go to the home page<\/a>/);
324
+ assert.doesNotMatch(readBuild("404.html"), /<script|<h1>Home<\/h1>/i);
325
+ assert.equal(readBuild("index.html"), ROOT_ONLY_HOME);
326
+ assert.deepEqual(rendered, []);
327
+ assert.equal(existsInBuild(`${NOT_FOUND_PROBE_ROUTE.slice(1)}/index.html`), false);
328
+ });
329
+ }
330
+
331
+ test("collector-to-artifacts: unsupported pathname-driven rendering stays non-authoritative", async (t) => {
332
+ const { routeTable, rendered } = await writeCollectedSiteFiles(t, {
333
+ "App.tsx": `export default function App() {
334
+ return window.location.pathname === "/about" ? <main><h1>About</h1></main> : <main><h1>Home</h1></main>;
335
+ }`,
336
+ });
337
+
338
+ assert.equal(routeTable.complete, false);
339
+ assert.equal(existsInBuild("_routes.json"), false);
340
+ assert.match(readBuild("404.html"), /<h1>Page not found<\/h1>/);
341
+ assert.doesNotMatch(readBuild("404.html"), /<script/i);
342
+ assert.equal(readBuild("index.html"), ROOT_ONLY_HOME);
343
+ assert.deepEqual(rendered, []);
344
+ });
345
+
346
+ for (const [form, helmet] of [
347
+ ["script prop", `<Helmet script={[{ src: "/dispatcher.js", async: true }]} />`],
348
+ ["body event child", `<Helmet><body onload='if (location.pathname === "/about") document.querySelector("main").innerHTML = "<h1>About</h1>";' /></Helmet>`],
349
+ ]) for (const routerFound of [false, true]) {
350
+ test(`collector-to-artifacts: an executable Helmet ${form} stays non-authoritative (routerFound: ${routerFound})`, async (t) => {
351
+ const { routeTable, rendered } = await writeCollectedSiteFiles(t, {
352
+ "App.tsx": `import { Helmet } from "react-helmet-async";
353
+ ${routerFound ? 'import { BrowserRouter } from "react-router-dom";' : ""}
354
+ export default function App() {
355
+ return ${routerFound ? "<BrowserRouter>" : ""}<main>${helmet}<h1>Home</h1></main>${routerFound ? "</BrowserRouter>" : ""};
356
+ }`,
357
+ "public/dispatcher.js": `if (location.pathname === "/about") document.querySelector("main").innerHTML = "<h1>About</h1>";`,
358
+ });
359
+
360
+ assert.deepEqual(routeTable, { routerFound, complete: false, hasCatchAll: false, patterns: ["/"] });
361
+ if (routerFound) assert.deepEqual(JSON.parse(readBuild("_routes.json")), { complete: false, patterns: ["/"] });
362
+ else assert.equal(existsInBuild("_routes.json"), false);
363
+ assert.equal(readBuild("index.html"), ROOT_ONLY_HOME);
364
+ assert.deepEqual(rendered, []);
365
+ });
366
+ }
367
+
368
+ test("collector-to-artifacts: SEO not switched on skips the root-only manifest and 404", async (t) => {
369
+ for (const settings of [{}, { siteUrl: "https://one-page.example-cdn.com", private: true }]) {
370
+ const { routeTable, rendered } = await writeCollectedSiteFiles(t, { "App.tsx": ROOT_ONLY_APP }, settings);
371
+
372
+ assert.equal(routeTable.complete, true);
373
+ assert.equal(existsInBuild("_routes.json"), false);
374
+ assert.equal(existsInBuild("404.html"), false);
375
+ assert.equal(readBuild("index.html"), ROOT_ONLY_HOME);
376
+ assert.deepEqual(rendered, []);
377
+ }
378
+ });
@@ -0,0 +1,29 @@
1
+ import { useEffect } from "react";
2
+ import { useLocation } from "react-router-dom";
3
+
4
+ /**
5
+ * ScrollTopRouting component
6
+ * Automatically scrolls to top of page when route changes
7
+ * Must be placed inside Router component (BrowserRouter/HashRouter)
8
+ * before the Routes component
9
+ */
10
+ function ScrollTopRouting() {
11
+ const { pathname, hash } = useLocation();
12
+
13
+ useEffect(() => {
14
+ if (hash) {
15
+ const id = hash.replace("#", "");
16
+ const el = document.getElementById(id);
17
+ if (el) {
18
+ setTimeout(() => el.scrollIntoView({ behavior: "smooth" }), 80);
19
+ }
20
+ } else {
21
+ window.scrollTo({ top: 0, left: 0, behavior: "instant" });
22
+ }
23
+ }, [pathname, hash]);
24
+
25
+ return null;
26
+ }
27
+
28
+ export default ScrollTopRouting;
29
+
@@ -0,0 +1,111 @@
1
+ import { useLayoutEffect } from "react";
2
+ import { Helmet } from "react-helmet-async";
3
+ import images from "@/assets/images.json";
4
+
5
+ interface SEOHeadProps {
6
+ titleKey?: string;
7
+ descriptionKey?: string;
8
+ ogImage?: string;
9
+ ogImageKey?: string;
10
+ noindex?: boolean;
11
+ }
12
+
13
+ const SEOHEAD_MARK = { "data-seohead": "" };
14
+ const CANONICAL_TAG = 'link[rel="canonical"]';
15
+ const OG_URL_TAG = 'meta[property="og:url"]';
16
+ const OG_SITE_NAME_TAG = 'meta[property="og:site_name"]';
17
+ const STATIC_URL_TAGS = new Set(
18
+ typeof document === "undefined" ? [] : Array.from(document.head.querySelectorAll(`${CANONICAL_TAG}, ${OG_URL_TAG}`)),
19
+ );
20
+
21
+ function normalizePath(pathname: string) {
22
+ return pathname.replace(/\/+$/, "") || "/";
23
+ }
24
+
25
+ function currentPageUrl() {
26
+ if (typeof window === "undefined" || (window as Window & { __PRERENDER__?: boolean }).__PRERENDER__) {
27
+ return undefined;
28
+ }
29
+ return `${window.location.origin}${normalizePath(window.location.pathname)}`;
30
+ }
31
+
32
+ function foreignHeadTags(selector: string) {
33
+ if (typeof document === "undefined") {
34
+ return [];
35
+ }
36
+ return Array.from(document.head.querySelectorAll(`${selector}:not([data-seohead])`));
37
+ }
38
+
39
+ function foreignTagMatchesPage(selector: string, urlAttribute: string) {
40
+ const url = foreignHeadTags(selector)[0]?.getAttribute(urlAttribute);
41
+ if (!url) {
42
+ return false;
43
+ }
44
+ try {
45
+ return normalizePath(new URL(url, window.location.href).pathname) === normalizePath(window.location.pathname);
46
+ } catch {
47
+ return false;
48
+ }
49
+ }
50
+
51
+ function removeStaticTags(selector: string) {
52
+ foreignHeadTags(selector)
53
+ .filter((tag) => STATIC_URL_TAGS.has(tag))
54
+ .forEach((tag) => tag.remove());
55
+ }
56
+
57
+ // Canonical <head> for every page. Render exactly one <SEOHead /> per page
58
+ // inside pages/*.tsx; no raw <Helmet> anywhere else. Pulls og:image from
59
+ // assets/images.json by default so social sharing works without extra wiring.
60
+ //
61
+ // titleKey / descriptionKey take the DISPLAY VALUE: literal text on a
62
+ // monolingual/inline site, or a pre-translated value (t('…')) when the project
63
+ // externalizes text. The legacy prop names are kept on purpose — existing
64
+ // projects ship a SEOHead with the same names (translating internally, where
65
+ // t() falls back to the given string), so one prompt directive stays correct
66
+ // for every project. No useLanguage() here: this component must not depend on
67
+ // LanguageProvider.
68
+ export function SEOHead({
69
+ titleKey = "Remixer AI by DreamHost",
70
+ descriptionKey = "AI website builder for everyone!",
71
+ ogImage,
72
+ ogImageKey = "og_image",
73
+ noindex = false,
74
+ }: SEOHeadProps) {
75
+ const siteTitle = import.meta.env.VITE_METADATA_TITLE as string | undefined;
76
+ const metaTitle = siteTitle ? `${titleKey} | ${siteTitle}` : titleKey;
77
+ const resolvedOg =
78
+ ogImage ?? (images as Record<string, string>)[ogImageKey];
79
+ const pageUrl = currentPageUrl();
80
+ const ownsCanonical = pageUrl !== undefined && !foreignTagMatchesPage(CANONICAL_TAG, "href");
81
+ const ownsOgUrl = pageUrl !== undefined && !foreignTagMatchesPage(OG_URL_TAG, "content");
82
+ const ownsSiteName = Boolean(siteTitle) && foreignHeadTags(OG_SITE_NAME_TAG).length === 0;
83
+
84
+ useLayoutEffect(() => {
85
+ if (ownsCanonical) {
86
+ removeStaticTags(CANONICAL_TAG);
87
+ }
88
+ if (ownsOgUrl) {
89
+ removeStaticTags(OG_URL_TAG);
90
+ }
91
+ }, [ownsCanonical, ownsOgUrl, pageUrl]);
92
+
93
+ return (
94
+ <Helmet>
95
+ <title>{metaTitle}</title>
96
+ <meta name="description" content={descriptionKey} />
97
+ <meta property="og:title" content={metaTitle} />
98
+ <meta property="og:description" content={descriptionKey} />
99
+ <meta property="og:type" content="website" />
100
+ {resolvedOg ? <meta property="og:image" content={resolvedOg} /> : null}
101
+ <meta name="twitter:card" content="summary_large_image" />
102
+ {resolvedOg ? <meta name="twitter:image" content={resolvedOg} /> : null}
103
+ {ownsSiteName ? <meta property="og:site_name" content={siteTitle} {...SEOHEAD_MARK} /> : null}
104
+ {ownsOgUrl ? <meta property="og:url" content={pageUrl} {...SEOHEAD_MARK} /> : null}
105
+ {ownsCanonical ? <link rel="canonical" href={pageUrl} {...SEOHEAD_MARK} /> : null}
106
+ {noindex ? <meta name="robots" content="noindex" /> : null}
107
+ </Helmet>
108
+ );
109
+ }
110
+
111
+ export default SEOHead;