routerino 2.2.0 → 2.2.2-rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,8 +4,6 @@
4
4
 
5
5
  For teams who want SPA simplicity with search-friendly static HTML, Open Graph previews, and **no framework lock-in.**
6
6
 
7
- <!-- [**Live Example**](https://www.papoir.com) | [**Starter Template**](https://github.com/nerds-with-keyboards/routerino-starter) -->
8
-
9
7
  Routerino is a zero-dependency router for React designed for optimal SEO performance in client-side rendered applications. Built for modern web architectures like JAMStack applications and Vite-powered React sites, it provides route & meta tag management, sitemap generation, and static site generation or [prerender](https://github.com/prerender/prerender) support to ensure your React applications are fully discoverable by search engines.
10
8
 
11
9
  ## Why Routerino?
@@ -204,7 +202,7 @@ The table below shows all available props with their default values. See the [us
204
202
 
205
203
  #### Routerino props
206
204
 
207
- All of these are optional, so it's easy to get started with nothing but a bare-bones `<Routerino />` element, to get started with a working sample page. The main props you'll need are `routes` and `title`. See [Route props](#routes-prop) for the route format.
205
+ All of these are optional, so it's easy to get started with nothing but a bare-bones `<Routerino />` element, to get started with a working sample page. The main props you'll need are `routes` and `title`. See [RouteConfig props](#routeconfig-props) for the route format.
208
206
 
209
207
  | Prop | Type | Description | Default |
210
208
  | ---------------------------------------------- | --------------- | --------------------------------- | ----------------------------- |
@@ -685,10 +683,9 @@ export default defineConfig({
685
683
  routerinoForge({
686
684
  baseUrl: "https://example.com", // Your production URL (no trailing slash)
687
685
  // Optional settings (these are the defaults):
688
- // routes: "./src/routes.jsx", // Your routes file
686
+ // routes: "./src/routes.jsx", // Your routes file (and App.jsx for full layout)
689
687
  // outputDir: "dist",
690
688
  // generateSitemap: true,
691
- // prerenderStatusCode: true,
692
689
  // useTrailingSlash: true, // Set to false for /about instead of /about/
693
690
  // verbose: false,
694
691
  // ssgCacheDir: "node_modules/.cache/routerino-forge", // SSG cache directory
@@ -749,6 +746,8 @@ routerinoForge({
749
746
  - Caches processed images to speed up subsequent builds
750
747
  - Preserves original images while enhancing loading performance
751
748
  - Skips external images (http/https), data URIs, and SVGs
749
+ - Smart sizing: Uses aspect-ratio only to prevent layout shift without forcing dimensions
750
+ - Hides images initially with `opacity: 0` to prevent broken image icons during load
752
751
 
753
752
  **Note:** Image optimization requires `ffmpeg` to be installed. Without it, images work normally but without blur placeholders. Install with `brew install ffmpeg` (Mac), `apt install ffmpeg` (Ubuntu), or `choco install ffmpeg` (Windows).
754
753
 
@@ -837,6 +836,28 @@ In your netlify.toml:
837
836
 
838
837
  **Critical for SSG**: Routes MUST be exported for the build plugin to discover them. The plugin needs to import your routes at build time, so inline route definitions won't work.
839
838
 
839
+ To include your full layout (headers, footers, etc.) in static generated HTML, export routes from the same file as your App component:
840
+
841
+ ```jsx
842
+ // App.jsx - export routes from here for full layout SSG
843
+ export const routes = [
844
+ { path: "/", element: <HomePage />, title: "Home" },
845
+ { path: "/about/", element: <AboutPage />, title: "About" },
846
+ ];
847
+
848
+ export default function App() {
849
+ return (
850
+ <main>
851
+ <Header />
852
+ <Routerino routes={routes} />
853
+ <Footer />
854
+ </main>
855
+ );
856
+ }
857
+ ```
858
+
859
+ This ensures your entire App layout is rendered during static site generation. Without this, SSG renders only the individual route elements.
860
+
840
861
  Define routes with an `element` property containing JSX elements:
841
862
 
842
863
  ```jsx
@@ -1005,9 +1026,7 @@ If you're starting from scratch and wondering "How do I create a React project w
1005
1026
  2. We recommend using [Vite](https://vitejs.dev/) for a fast and lean development experience. Vite is a modern build tool that focuses on speed and simplicity. To create a new React project with Vite, run the following command in your terminal:
1006
1027
 
1007
1028
  ```
1008
-
1009
1029
  npm create vite@latest my-react-app -- --template react
1010
-
1011
1030
  ```
1012
1031
 
1013
1032
  This command will create a new directory called `my-react-app` with a basic React project structure.
@@ -1015,17 +1034,13 @@ This command will create a new directory called `my-react-app` with a basic Reac
1015
1034
  3. Navigate to your new project directory:
1016
1035
 
1017
1036
  ```
1018
-
1019
1037
  cd my-react-app
1020
-
1021
1038
  ```
1022
1039
 
1023
1040
  4. Install the project dependencies using npm:
1024
1041
 
1025
1042
  ```
1026
-
1027
1043
  npm install
1028
-
1029
1044
  ```
1030
1045
 
1031
1046
  This command will read the `package.json` file in your project and install all the necessary dependencies.
@@ -1033,14 +1048,12 @@ This command will read the `package.json` file in your project and install all t
1033
1048
  5. Now, add Routerino to your project as a dependency:
1034
1049
 
1035
1050
  ```
1036
-
1037
1051
  npm install routerino
1038
-
1039
1052
  ```
1040
1053
 
1041
1054
  This command will install the latest version of Routerino and save it to your `package.json` file under the `dependencies` section.
1042
1055
 
1043
- With these steps, you'll have a new React project set up with Vite as the build tool and Routerino installed as a development dependency. You can now start building your application with React & Routerino.
1056
+ With these steps, you'll have a new React project set up with Vite as the build tool and Routerino installed as a dependency. You can now start building your application with React & Routerino.
1044
1057
 
1045
1058
  ### Full React Example
1046
1059
 
@@ -1207,6 +1220,7 @@ By vendoring Routerino, you have full control over the code and can make any nec
1207
1220
 
1208
1221
  Here are some sources for further reading on SEO best-practices.
1209
1222
 
1223
+ - [Optimize Largest Contentful Paint (LCP)](https://web.dev/articles/optimize-lcp) - Improve loading performance
1210
1224
  - [Apple's best practices for link previews](https://developer.apple.com/library/archive/technotes/tn2444/_index.html)
1211
1225
  - [Use Open Graph tags](https://ahrefs.com/blog/open-graph-meta-tags/)
1212
1226
  - [Use descriptive link text](https://developers.google.com/search/docs/fundamentals/seo-starter-guide?hl=en&ref_topic=9460495)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routerino",
3
- "version": "2.2.0",
3
+ "version": "2.2.2-rc1",
4
4
  "description": "A lightweight, SEO-optimized React router for modern web applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -217,84 +217,118 @@ async function processImagesInHTML(html, outputDir, config) {
217
217
  const cacheDir = path.resolve(imageConfig.cacheDir);
218
218
  await fs.mkdir(cacheDir, { recursive: true }).catch(() => {});
219
219
 
220
- // Find all img tags
220
+ // Find all img tags - process in chunks to optimize memory
221
221
  const imgRegex = /<img\s+([^>]*?)src=["']([^"']+)["']([^>]*?)>/gi;
222
- const matches = [...html.matchAll(imgRegex)];
223
-
224
222
  let processedHTML = html;
225
223
 
226
- for (const match of matches) {
227
- // eslint-disable-next-line no-unused-vars
228
- const [fullMatch, beforeSrc, src, afterSrc] = match;
229
-
230
- // Skip external URLs, data URLs, and SVGs
231
- if (shouldSkipImage(src)) {
232
- stats.skipped++;
233
- continue;
224
+ // Process images in batches to avoid loading all matches at once
225
+ const BATCH_SIZE = 10; // Process 10 images at a time
226
+ let match;
227
+ let batchCount = 0;
228
+ let currentBatch = [];
229
+
230
+ while ((match = imgRegex.exec(html)) !== null) {
231
+ currentBatch.push(match);
232
+ batchCount++;
233
+
234
+ // Process batch when it reaches the size limit
235
+ if (batchCount >= BATCH_SIZE) {
236
+ processedHTML = await processBatch(currentBatch, processedHTML);
237
+ currentBatch = [];
238
+ batchCount = 0;
234
239
  }
240
+ }
235
241
 
236
- // Resolve image path
237
- const imagePath = path.join(outputDir, src);
242
+ // Process remaining images in the last batch
243
+ if (currentBatch.length > 0) {
244
+ processedHTML = await processBatch(currentBatch, processedHTML);
245
+ }
238
246
 
239
- // Check if file exists and size
240
- try {
241
- const fileStats = await fs.stat(imagePath);
247
+ // Helper function to process a batch of images
248
+ async function processBatch(batch, html) {
249
+ let result = html;
242
250
 
243
- if (
244
- fileStats.size < imageConfig.minSize ||
245
- fileStats.size > imageConfig.maxSize
246
- ) {
251
+ for (const match of batch) {
252
+ // eslint-disable-next-line no-unused-vars
253
+ const [fullMatch, beforeSrc, src, afterSrc] = match;
254
+
255
+ // Skip external URLs, data URLs, and SVGs
256
+ if (shouldSkipImage(src)) {
247
257
  stats.skipped++;
248
258
  continue;
249
259
  }
250
260
 
251
- stats.totalSize += fileStats.size;
261
+ // Resolve image path with safety check
262
+ const imagePath = path.join(outputDir, src);
252
263
 
253
- // Check cache
254
- const cacheKey = await getImageCacheKey(imagePath, imageConfig);
255
- if (!cacheKey) {
264
+ // Path traversal protection: ensure resolved path is within outputDir
265
+ const resolvedPath = path.resolve(imagePath);
266
+ const resolvedOutputDir = path.resolve(outputDir);
267
+ if (!resolvedPath.startsWith(resolvedOutputDir)) {
268
+ // Skip potentially malicious paths
256
269
  stats.skipped++;
257
270
  continue;
258
271
  }
259
272
 
260
- const cacheFile = path.join(cacheDir, `${cacheKey}.json`);
261
- let imageData;
262
-
273
+ // Check if file exists and size
263
274
  try {
264
- // Try to read from cache
265
- const cached = JSON.parse(await fs.readFile(cacheFile, "utf-8"));
266
- imageData = cached;
267
- } catch {
268
- // Generate placeholder
269
- imageData = await generatePlaceholder(imagePath, imageConfig);
270
-
271
- if (!imageData) {
275
+ const fileStats = await fs.stat(resolvedPath);
276
+
277
+ if (
278
+ fileStats.size < imageConfig.minSize ||
279
+ fileStats.size > imageConfig.maxSize
280
+ ) {
272
281
  stats.skipped++;
273
282
  continue;
274
283
  }
275
284
 
276
- // Save to cache
277
- await fs
278
- .writeFile(cacheFile, JSON.stringify(imageData))
279
- .catch(() => {});
280
- }
285
+ stats.totalSize += fileStats.size;
281
286
 
282
- const dataUri = imageData.placeholder;
287
+ // Check cache
288
+ const cacheKey = await getImageCacheKey(resolvedPath, imageConfig);
289
+ if (!cacheKey) {
290
+ stats.skipped++;
291
+ continue;
292
+ }
283
293
 
284
- // Create a wrapper span with the blurred background positioned behind
285
- // Set explicit dimensions if we have them to show placeholder before image loads
286
- let spanStyle = `position: relative; display: inline-block;`;
294
+ const cacheFile = path.join(cacheDir, `${cacheKey}.json`);
295
+ let imageData;
287
296
 
288
- if (imageData.width && imageData.height) {
289
- spanStyle += ` width: ${imageData.width}px; height: ${imageData.height}px;`;
290
- }
297
+ try {
298
+ // Try to read from cache
299
+ const cached = JSON.parse(await fs.readFile(cacheFile, "utf-8"));
300
+ imageData = cached;
301
+ } catch {
302
+ // Generate placeholder
303
+ imageData = await generatePlaceholder(resolvedPath, imageConfig);
291
304
 
292
- // Create the blur background as a pseudo-element that sits behind (z-index: -1)
293
- // Using a unique class to avoid conflicts
294
- const uniqueClass = `lqip-${Math.random().toString(36).substr(2, 9)}`;
305
+ if (!imageData) {
306
+ stats.skipped++;
307
+ continue;
308
+ }
309
+
310
+ // Save to cache
311
+ await fs
312
+ .writeFile(cacheFile, JSON.stringify(imageData))
313
+ .catch(() => {});
314
+ }
315
+
316
+ const dataUri = imageData.placeholder;
295
317
 
296
- // Style for the ::before pseudo-element that creates the blur background
297
- const beforeStyle = `
318
+ // Create a wrapper span with the blurred background positioned behind
319
+ let spanStyle = `position: relative; display: inline-block;`;
320
+
321
+ if (imageData.width && imageData.height) {
322
+ // Set aspect-ratio for proper proportions
323
+ const aspectRatio = imageData.width / imageData.height;
324
+ spanStyle += ` aspect-ratio: ${aspectRatio};`;
325
+ }
326
+
327
+ // Create the blur background as a pseudo-element that sits behind (z-index: -1)
328
+ // Using a unique class to avoid conflicts
329
+ const uniqueClass = `lqip-${Math.random().toString(36).substring(2, 11)}`;
330
+ // Style for the ::before pseudo-element that creates the blur background
331
+ const beforeStyle = `
298
332
  .${uniqueClass}::before {
299
333
  content: '';
300
334
  position: absolute;
@@ -310,20 +344,44 @@ async function processImagesInHTML(html, outputDir, config) {
310
344
  }
311
345
  `;
312
346
 
313
- const styleTag = `<style>${beforeStyle}</style>`;
347
+ const styleTag = `<style>${beforeStyle}</style>`;
348
+
349
+ // Add opacity: 0 inline style to hide image initially
350
+ const imgWithOpacity = fullMatch.replace(
351
+ /(<img\s+[^>]*?)(\s*\/?>)/i,
352
+ (_, p1, p2) => {
353
+ let result = p1;
354
+
355
+ // Add opacity to style
356
+ if (/style\s*=/i.test(result)) {
357
+ // Add to existing style
358
+ result = result.replace(
359
+ /style\s*=\s*["']([^"']*)/i,
360
+ 'style="opacity: 0; $1'
361
+ );
362
+ } else {
363
+ // Add new style attribute
364
+ result += ' style="opacity: 0"';
365
+ }
314
366
 
315
- // Wrap img in span with blur background, leaving img completely untouched
316
- const wrappedImg = `${styleTag}<span class="forge-lqip ${uniqueClass}" style="${spanStyle}">${fullMatch}</span>`;
367
+ return result + p2;
368
+ }
369
+ );
317
370
 
318
- processedHTML = processedHTML.replace(fullMatch, wrappedImg);
371
+ // Wrap img in span with blur background
372
+ const wrappedImg = `${styleTag}<span class="forge-lqip ${uniqueClass}" style="${spanStyle}">${imgWithOpacity}</span>`;
319
373
 
320
- stats.processed++;
321
- stats.placeholderSize += imageData.placeholder.length;
322
- } catch (error) {
323
- stats.errors.push({ src, error: error.message });
324
- stats.skipped++;
325
- }
326
- }
374
+ result = result.replace(fullMatch, wrappedImg);
375
+
376
+ stats.processed++;
377
+ stats.placeholderSize += imageData.placeholder.length;
378
+ } catch (error) {
379
+ stats.errors.push({ src, error: error.message });
380
+ stats.skipped++;
381
+ }
382
+ } // End of for loop in processBatch
383
+ return result;
384
+ } // End of processBatch function
327
385
 
328
386
  return { html: processedHTML, stats };
329
387
  }
@@ -368,6 +426,9 @@ export function routerinoForge(options = {}) {
368
426
  },
369
427
 
370
428
  async closeBundle() {
429
+ // Only run during build, not during dev server
430
+ if (viteConfig.command !== "build") return;
431
+
371
432
  if (hasRun || viteConfig.build.ssr) return; // Skip if already run or if this IS the SSG build
372
433
  hasRun = true;
373
434
 
@@ -407,22 +468,103 @@ export function routerinoForge(options = {}) {
407
468
  import React from 'react';
408
469
  import ReactDOMServer from 'react-dom/server';
409
470
  import * as routesModule from '${relativePath.startsWith(".") ? relativePath : "./" + relativePath}';
471
+
410
472
  // Support different export patterns
411
473
  const routes = routesModule.routes || routesModule.default;
412
474
  const notFoundTemplate = routesModule.notFoundTemplate;
475
+
476
+ // Check if App component is exported from routes file
477
+ const App = routesModule.App || routesModule.default?.App;
478
+
413
479
  if (!routes) {
414
480
  throw new Error('Could not find routes export. Expected "export const routes" or "export default" from ${relativePath}');
415
481
  }
482
+
416
483
  // Helper to check if a route is dynamic (contains :param)
417
484
  const isDynamicRoute = (path) => path.split("/").some(segment => segment.startsWith(":"));
418
485
  export { routes };
419
- export function render(url) {
486
+
487
+ // Mock minimal window object for SSG
488
+ function mockWindow(url, baseUrl) {
489
+ const urlObj = new URL(url, baseUrl || 'http://localhost');
490
+ global.window = {
491
+ location: {
492
+ href: urlObj.href,
493
+ pathname: urlObj.pathname,
494
+ search: urlObj.search,
495
+ hash: urlObj.hash,
496
+ origin: urlObj.origin,
497
+ protocol: urlObj.protocol,
498
+ host: urlObj.host,
499
+ hostname: urlObj.hostname,
500
+ port: urlObj.port
501
+ },
502
+ history: {
503
+ pushState: () => {},
504
+ replaceState: () => {}
505
+ },
506
+ scrollTo: () => {},
507
+ addEventListener: () => {},
508
+ removeEventListener: () => {},
509
+ dispatchEvent: () => {}
510
+ };
511
+ global.document = {
512
+ addEventListener: () => {},
513
+ removeEventListener: () => {},
514
+ querySelector: () => null,
515
+ createElement: () => ({
516
+ setAttribute: () => {},
517
+ appendChild: () => {}
518
+ }),
519
+ head: {
520
+ appendChild: () => {}
521
+ }
522
+ };
523
+ }
524
+
525
+ export function render(url, baseUrl) {
526
+ // Check if we should render the full App or just the route element
527
+ if (App) {
528
+ // Mock window for the current route
529
+ mockWindow(url, baseUrl);
530
+
531
+ try {
532
+ // Render the full App component (which includes Routerino)
533
+ const html = ReactDOMServer.renderToString(React.createElement(App));
534
+
535
+ // Find the rendered route to get its metadata
536
+ const route = routes.find(r => {
537
+ if (r.path === url) return true;
538
+ if (r.path === '/' && url === '/') return true;
539
+ if (isDynamicRoute(r.path)) return false;
540
+ return r.path === url;
541
+ });
542
+
543
+ return {
544
+ html,
545
+ title: route?.title,
546
+ description: route?.description,
547
+ imageUrl: route?.imageUrl,
548
+ notFound: !route
549
+ };
550
+ } catch (error) {
551
+ console.error(\`[Routerino Forge] Failed to render App for route \${url}:\`, error.message);
552
+ // Fall back to route-only rendering
553
+ } finally {
554
+ // Clean up global mocks
555
+ delete global.window;
556
+ delete global.document;
557
+ }
558
+ }
559
+
560
+ // Original behavior: render just the route element
420
561
  const route = routes.find(r => {
421
562
  if (r.path === url) return true;
422
563
  if (r.path === '/' && url === '/') return true;
423
564
  if (isDynamicRoute(r.path)) return false;
424
565
  return r.path === url;
425
566
  });
567
+
426
568
  if (!route) {
427
569
  if (notFoundTemplate) {
428
570
  const notFoundHTML = ReactDOMServer.renderToString(notFoundTemplate);
@@ -430,6 +572,7 @@ export function render(url) {
430
572
  }
431
573
  return { html: '<div><h1>404 - Page Not Found</h1><p>The page you are looking for does not exist.</p></div>', notFound: true };
432
574
  }
575
+
433
576
  try {
434
577
  const html = ReactDOMServer.renderToString(route.element);
435
578
  return {
@@ -549,7 +692,7 @@ export function render(url) {
549
692
  }
550
693
 
551
694
  // Check if template has the root div
552
- if (!template.includes('<div id="root">')) {
695
+ if (!/<div[^>]*\sid=["']root["'][^>]*>/i.test(template)) {
553
696
  console.warn(
554
697
  '[Routerino Forge] Template missing <div id="root">. The plugin needs this to inject rendered HTML.'
555
698
  );
@@ -683,8 +826,8 @@ async function generateStaticPages({
683
826
  }
684
827
 
685
828
  try {
686
- // Use the render function to generate HTML
687
- const renderResult = render(route.path);
829
+ // Use the render function to generate HTML (pass baseUrl for window mocking)
830
+ const renderResult = render(route.path, config.baseUrl);
688
831
 
689
832
  let renderedHTML = "";
690
833
  if (renderResult.notFound) {
@@ -774,11 +917,10 @@ async function generateStaticPages({
774
917
  html = html.replace("</head>", ` ${metaTags}\n </head>`);
775
918
 
776
919
  // Replace root div content with rendered HTML
777
- if (html.includes('<div id="root">')) {
778
- html = html.replace(
779
- /<div id="root">.*?<\/div>/s,
780
- `<div id="root">${renderedHTML}</div>`
781
- );
920
+ const rootDivRegex =
921
+ /(<div[^>]*\sid=["']root["'][^>]*>)(.*?)(<\/div>)/is;
922
+ if (rootDivRegex.test(html)) {
923
+ html = html.replace(rootDivRegex, `$1${renderedHTML}$3`);
782
924
  } else {
783
925
  console.warn(
784
926
  `[Routerino Forge] Could not find <div id="root"> for ${route.path}`
@@ -823,6 +965,29 @@ async function generateStaticPages({
823
965
  return allImageStats;
824
966
  }
825
967
 
968
+ // Helper to safely handle meta tag content
969
+ // If content has double quotes, use single quotes for the attribute
970
+ // If content has single quotes, use double quotes for the attribute
971
+ // If it has both, replace double quotes with smart quotes
972
+ function formatMetaAttribute(attrName, content) {
973
+ if (!content) return "";
974
+
975
+ const hasDoubleQuotes = content.includes('"');
976
+ const hasSingleQuotes = content.includes("'");
977
+
978
+ if (hasDoubleQuotes && hasSingleQuotes) {
979
+ // Replace straight quotes with smart quotes which look better and don't break HTML
980
+ const safeContent = content.replace(/"/g, '"'); // Replace " with smart quote
981
+ return `${attrName}="${safeContent}"`;
982
+ } else if (hasDoubleQuotes) {
983
+ // Use single quotes for attribute
984
+ return `${attrName}='${content}'`;
985
+ } else {
986
+ // Use double quotes (default)
987
+ return `${attrName}="${content}"`;
988
+ }
989
+ }
990
+
826
991
  // Generate meta tags
827
992
  function generateMetaTags(route, config, urlPath) {
828
993
  const tags = [];
@@ -843,17 +1008,21 @@ function generateMetaTags(route, config, urlPath) {
843
1008
  tags.push(`<link rel="canonical" href="${canonicalUrl}">`);
844
1009
 
845
1010
  if (route.description) {
846
- tags.push(`<meta name="description" content="${route.description}">`);
1011
+ tags.push(
1012
+ `<meta name="description" ${formatMetaAttribute("content", route.description)}>`
1013
+ );
847
1014
  }
848
1015
 
849
1016
  // Open Graph tags
850
1017
  if (route.title) {
851
- tags.push(`<meta property="og:title" content="${route.title}">`);
1018
+ tags.push(
1019
+ `<meta property="og:title" ${formatMetaAttribute("content", route.title)}>`
1020
+ );
852
1021
  }
853
1022
 
854
1023
  if (route.description) {
855
1024
  tags.push(
856
- `<meta property="og:description" content="${route.description}">`
1025
+ `<meta property="og:description" ${formatMetaAttribute("content", route.description)}>`
857
1026
  );
858
1027
  }
859
1028
 
@@ -876,7 +1045,7 @@ function generateMetaTags(route, config, urlPath) {
876
1045
  const tagName = tag.tag || "meta";
877
1046
  const attrs = Object.entries(tag)
878
1047
  .filter(([key]) => key !== "tag" && key !== "soft")
879
- .map(([key, value]) => `${key}="${value}"`)
1048
+ .map(([key, value]) => formatMetaAttribute(key, value))
880
1049
  .join(" ");
881
1050
 
882
1051
  if (attrs) {
@@ -894,7 +1063,10 @@ async function generate404Page({ template, outputDir, config, render }) {
894
1063
 
895
1064
  try {
896
1065
  // Render a non-existent route to get the notFoundTemplate content
897
- const renderResult = render("/this-route-does-not-exist-404");
1066
+ const renderResult = render(
1067
+ "/this-route-does-not-exist-404",
1068
+ config.baseUrl
1069
+ );
898
1070
 
899
1071
  // The render function will return the notFoundTemplate HTML
900
1072
  const renderedHTML = renderResult.html || "404 - Page Not Found";
@@ -934,11 +1106,9 @@ async function generate404Page({ template, outputDir, config, render }) {
934
1106
  html = html.replace("</head>", ` ${metaTags.join("\n")}\n </head>`);
935
1107
 
936
1108
  // Replace root div content with rendered HTML
937
- if (html.includes('<div id="root">')) {
938
- html = html.replace(
939
- /<div id="root">.*?<\/div>/s,
940
- `<div id="root">${renderedHTML}</div>`
941
- );
1109
+ const rootDivRegex = /(<div[^>]*\sid=["']root["'][^>]*>)(.*?)(<\/div>)/is;
1110
+ if (rootDivRegex.test(html)) {
1111
+ html = html.replace(rootDivRegex, `$1${renderedHTML}$3`);
942
1112
  } else {
943
1113
  console.warn(
944
1114
  '[Routerino Forge] Could not find <div id="root"> for 404.html'