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 +28 -14
- package/package.json +1 -1
- package/routerino-forge.js +252 -82
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 [
|
|
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
|
|
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
package/routerino-forge.js
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
237
|
-
|
|
242
|
+
// Process remaining images in the last batch
|
|
243
|
+
if (currentBatch.length > 0) {
|
|
244
|
+
processedHTML = await processBatch(currentBatch, processedHTML);
|
|
245
|
+
}
|
|
238
246
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
247
|
+
// Helper function to process a batch of images
|
|
248
|
+
async function processBatch(batch, html) {
|
|
249
|
+
let result = html;
|
|
242
250
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
261
|
+
// Resolve image path with safety check
|
|
262
|
+
const imagePath = path.join(outputDir, src);
|
|
252
263
|
|
|
253
|
-
//
|
|
254
|
-
const
|
|
255
|
-
|
|
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
|
-
|
|
261
|
-
let imageData;
|
|
262
|
-
|
|
273
|
+
// Check if file exists and size
|
|
263
274
|
try {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
277
|
-
await fs
|
|
278
|
-
.writeFile(cacheFile, JSON.stringify(imageData))
|
|
279
|
-
.catch(() => {});
|
|
280
|
-
}
|
|
285
|
+
stats.totalSize += fileStats.size;
|
|
281
286
|
|
|
282
|
-
|
|
287
|
+
// Check cache
|
|
288
|
+
const cacheKey = await getImageCacheKey(resolvedPath, imageConfig);
|
|
289
|
+
if (!cacheKey) {
|
|
290
|
+
stats.skipped++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
283
293
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
let spanStyle = `position: relative; display: inline-block;`;
|
|
294
|
+
const cacheFile = path.join(cacheDir, `${cacheKey}.json`);
|
|
295
|
+
let imageData;
|
|
287
296
|
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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
|
-
|
|
297
|
-
|
|
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
|
-
|
|
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
|
-
|
|
316
|
-
|
|
367
|
+
return result + p2;
|
|
368
|
+
}
|
|
369
|
+
);
|
|
317
370
|
|
|
318
|
-
|
|
371
|
+
// Wrap img in span with blur background
|
|
372
|
+
const wrappedImg = `${styleTag}<span class="forge-lqip ${uniqueClass}" style="${spanStyle}">${imgWithOpacity}</span>`;
|
|
319
373
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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]) =>
|
|
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(
|
|
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
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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'
|