routerino 1.1.9 → 1.2.0

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
@@ -2,13 +2,41 @@
2
2
 
3
3
  > A lightweight, SEO-optimized React router for modern websites and applications
4
4
 
5
- Routerino is a zero-dependency router tailored for [React](https://reactjs.org/) [client-side rendered (CSR)](https://developers.google.com/web/updates/2019/02/rendering-on-the-web#csr) websites - perfect for modern web architectures like [JAMStack](https://jamstack.org/) or simple [Vite.js](https://vitejs.dev/)-React sites. It supports [Prerender](https://github.com/prerender/prerender) tags for SEO-friendly redirects and HTTP status codes, and can **automatically generate a sitemap.xml** file from your routes. Routerino simplifies client-side routing in React apps while providing handy SEO optimizations out of the box - a **minimalist router with SEO benefits**.
6
-
7
- As a developer, I've always been passionate about creating user-friendly applications and websites. However, I've faced challenges in routing and SEO optimization for React client-side rendered (CSR) websites. For years, the de facto routing monoculture has stifled diversity and innovation in the React ecosystem. Moreover, keeping up with the frequent API churn has been time-consuming and frustrating. Driven by these challenges, I set out to create Routerino — a lightweight, zero-dependency router that simplifies routing and offers excellent SEO benefits.
8
-
9
- I also wanted the ability to steer clear of the JSX-soup that has become prevalent in the React ecosystem. HTML is a powerful tool and it's often enough. Attempts to abstract away the web browser have led to excessive complexity, an endless black hole of bugs, and poor developer experience and delivery speed. If you've encountered such issues, you know exactly what I mean. By using plain HTML in JSX, we can build applications with simplicity. We shouldn't use this as an excuse to introduce needless complexity.
10
-
11
- Here's a quick example of what using Routerino looks like:
5
+ Routerino is a zero-dependency router for React (17/18/19) 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.
6
+
7
+ ## Why Routerino?
8
+
9
+ - **SEO-First Design**: Automatic meta tag management, sitemap generation, and prerender support ensure maximum search engine visibility
10
+ - **Zero Dependencies**: Keeps bundle size minimal and reduces supply-chain vulnerabilities
11
+ - **Simple API**: No special `Link` components required - use standard HTML anchors and navigate programmatically with standard browser APIs
12
+ - **Static Site Generation**: Build-tool agnostic static HTML generation for improved performance and SEO
13
+ - **Production Ready**: Includes Docker-based prerender server for easy deployments
14
+ - **Single File Core**: The entire routing logic fits in one file (~420 lines), making it easy to understand and customize
15
+
16
+ ## Table of Contents
17
+
18
+ - [Features](#features)
19
+ - [Installation](#installation)
20
+ - [Usage](#usage)
21
+ - [Props](#props-arguments)
22
+ - [Get route parameters](#get-route-parameters-and-the-current-route-and-updating-head-tags)
23
+ - [updateHeadTag](#updateheadtag)
24
+ - [Best Practices](#routerino-best-practices)
25
+ - [Generating a Sitemap](#generating-a-sitemap-from-routes)
26
+ - [Static Site Generation](#static-site-generation)
27
+ - [Deployment Guides](#deployment-guides)
28
+ - [Prerender Server (Docker)](#prerender-server-docker)
29
+ - [How-to Guides & Examples](#how-to-guides--example-code)
30
+ - [Starting a New Project](#starting-a-new-react-project-with-routerino)
31
+ - [Full React Example](#full-react-example)
32
+ - [Basic Example](#basic-example)
33
+ - [ErrorBoundary Component](#errorboundary-component)
34
+ - [Vendoring Routerino](#vendoring-routerino)
35
+ - [Additional Resources](#additional-resources)
36
+ - [Contributions](#contributions)
37
+ - [License](#license)
38
+
39
+ ## Quick Start
12
40
 
13
41
  ```jsx
14
42
  <Routerino
@@ -36,36 +64,28 @@ Here's a quick example of what using Routerino looks like:
36
64
  />
37
65
  ```
38
66
 
39
- For more details on getting started, see the [Installation](#installation) and [Usage](#usage) sections below.
67
+ This simple configuration automatically handles routing, meta tags, and SEO optimization for your React application.
40
68
 
41
69
  ## Features
42
70
 
43
- Routerino empowers developers to define and manage routing and SEO concerns in one centralized location. This approach eliminates duplication when creating sitemaps and setting page metadata, such as descriptions or open-graph tags. The core of Routerino fits in a single file, making it [easy to vendor](#vendoring-routerino) if that suits your needs.
44
-
45
- Key capabilities:
46
-
47
71
  - Routing
48
-
49
- - Easy integration of simple routing for your React app (supports React v18, older versions have not yet been tested)
72
+ - Easy integration of simple routing for your React app (supports React versions 17, 18, and 19)
50
73
  - Zero dependencies for lighter, more maintainable projects
51
74
  - No special link components required, works great for Markdown-based pages and semantic HTML
52
75
 
53
76
  - SEO Optimization
54
-
55
77
  - Configure title, description, and image for each route
56
78
  - Set `<head>` tags for any route (either directly in your routes config, or dynamically after rendering)
57
79
  - Set a site-wide name to be included with page titles
58
80
  - Automatically generate and maintain an up-to-date `sitemap.xml` from your routes
81
+ - Generate static HTML files for each route with proper meta tags
59
82
  - Implement SEO best practices out-of-the-box
60
83
  - Optimize for Googlebot with pre-rendering support
61
84
 
62
85
  - Enhanced User Experience
63
-
64
86
  - Support for sharing and social preview metadata
65
87
  - Snappy page transitions with automatic scroll reset, eliminating the jarring experience of landing mid-page when navigating
66
88
 
67
- Routerino is designed to work with modern browsers and has been tested with the latest versions of Chrome, Firefox, Safari, and Edge.
68
-
69
89
  ## Installation
70
90
 
71
91
  Ensure that you have React and React DOM installed in your project as peer dependencies. To add as a dev dependency:
@@ -74,6 +94,14 @@ Ensure that you have React and React DOM installed in your project as peer depen
74
94
  npm i routerino -D
75
95
  ```
76
96
 
97
+ ### Compatibility
98
+
99
+ Routerino supports:
100
+
101
+ - **React 17, 18, and 19** - All versions are tested and supported
102
+ - **Preact** - Compatible via `@preact/compat` (needs verification)
103
+ - **Node.js 18+** - Tested on Node.js 18, 20, and 22. Can run on earlier versions if we drop tests.
104
+
77
105
  ## Usage
78
106
 
79
107
  Here's a quick example of using Routerino in your React application:
@@ -92,7 +120,7 @@ Here's a quick example of using Routerino in your React application:
92
120
  />
93
121
  ```
94
122
 
95
- Links are just regular HTML anchor tags. No need to use special `<Link>` components and you can handle styling however you wish. For example: `<a href="/some-page/>a link</a>`
123
+ Links are just regular HTML anchor tags. No need to use special `<Link>` components and you can handle styling however you wish. For example: `<a href="/some-page/">a link</a>`
96
124
 
97
125
  See [props](#props-arguments) for full explanations and [example code](#how-to-guides--example-code) for more complete code samples.
98
126
 
@@ -138,7 +166,7 @@ See [RouteConfig props](#routeconfig-props) for more details. At a minimum a pat
138
166
 
139
167
  ##### `separator`: string;
140
168
 
141
- A string to separate the page title from the site title. The default is `|` (a pipe character w/space around). Set this to customize the separator.
169
+ A string to separate the page title from the site title. The default is `" | "` (a pipe character w/space around). Set this to customize the separator.
142
170
 
143
171
  ##### `notFoundTemplate`: element;
144
172
 
@@ -405,11 +433,84 @@ Add `routerino-build-sitemap` to your build command to update automatically on e
405
433
 
406
434
  Example package.json build script: `"build": "vite build && routerino-build-sitemap routeFilePath=src/routes.jsx hostname=https://example.com outputDir=dist",`
407
435
 
436
+ ## Static Site Generation
437
+
438
+ Routerino includes a build-tool agnostic static site generator that creates HTML files for each route, improving SEO and initial page load performance.
439
+
440
+ ### How It Works
441
+
442
+ The `routerino-build-static` command is a **post-build step** that works with ANY build tool (Vite, Webpack, Parcel, etc.):
443
+
444
+ 1. **First**: Build your app with your preferred build tool (`npm run build`)
445
+ 2. **Then**: Run `routerino-build-static` to generate static HTML files
446
+
447
+ ```sh
448
+ # After your build completes (creates dist/index.html with bundled JS/CSS):
449
+ routerino-build-static routesFile=src/routes.jsx outputDir=dist template=dist/index.html baseUrl=https://example.com
450
+ ```
451
+
452
+ **Parameters:**
453
+
454
+ - `routesFile` - Path to your routes configuration file (supports .js, .jsx, .ts, .tsx)
455
+ - `outputDir` - Directory where static HTML files will be generated (usually your build output)
456
+ - `template` - Your **built** HTML file with bundled assets (e.g., dist/index.html)
457
+ - `baseUrl` - Base URL for meta tags (optional but recommended for SEO)
458
+
459
+ ### Build Tool Examples
460
+
461
+ Works with any build tool:
462
+
463
+ ```json
464
+ // Vite
465
+ "build": "vite build && routerino-build-static routesFile=src/routes.js outputDir=dist template=dist/index.html"
466
+
467
+ // Webpack
468
+ "build": "webpack && routerino-build-static routesFile=src/routes.js outputDir=build template=build/index.html"
469
+
470
+ // Parcel
471
+ "build": "parcel build index.html && routerino-build-static routesFile=src/routes.js outputDir=dist template=dist/index.html"
472
+ ```
473
+
474
+ ### What Gets Generated
475
+
476
+ The static build process will:
477
+
478
+ - Generate an HTML file for each non-dynamic route (routes with `:param` are skipped)
479
+ - Apply route-specific meta tags (title, description, og:tags, custom tags)
480
+ - Add proper `data-route` attributes for client-side hydration
481
+ - Preserve your existing HTML structure and assets
482
+
483
+ ### Example Output
484
+
485
+ For a route configuration like:
486
+
487
+ ```javascript
488
+ {
489
+ path: '/about',
490
+ title: 'About Us',
491
+ description: 'Learn more about our company',
492
+ imageUrl: 'https://example.com/about-og.jpg'
493
+ }
494
+ ```
495
+
496
+ The generated `/about.html` will include:
497
+
498
+ ```html
499
+ <title>About Us</title>
500
+ <meta name="description" content="Learn more about our company" />
501
+ <meta property="og:title" content="About Us" />
502
+ <meta property="og:description" content="Learn more about our company" />
503
+ <meta property="og:image" content="https://example.com/about-og.jpg" />
504
+ <meta property="og:url" content="https://example.com/about" />
505
+ ```
506
+
507
+ This provides excellent SEO while maintaining the benefits of a React SPA.
508
+
408
509
  ## How-to Guides & Example Code
409
510
 
410
511
  1. [Starting a New React Project with Routerino](#starting-a-new-react-project-with-routerino)
411
- 2. [Basic Example](#basic-example)
412
- 3. [Full React Example](#full-react-example)
512
+ 2. [Full React Example](#full-react-example)
513
+ 3. [Basic Example](#basic-example)
413
514
 
414
515
  ### Starting a New React Project with Routerino
415
516
 
@@ -457,40 +558,15 @@ This command will install the latest version of Routerino and save it to your `p
457
558
 
458
559
  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.
459
560
 
460
- ### Basic Example
561
+ ### Full React Example
461
562
 
462
- Somewhere in your project, such as in your `src/App.jsx` file, import Routerino and add it to your code. Define your routes and configure the site title.
563
+ This example includes the full React configuration. It might take the place of `src/main.jsx` or an `index.js` file.
463
564
 
464
565
  ```jsx
465
566
  import React from "react";
567
+ import { render } from "react-dom";
466
568
  import Routerino from "routerino";
467
569
 
468
- // example pages
469
- import HomePage from "./HomePage";
470
- import AboutPage from "./AboutPage";
471
- import ContactPage from "./ContactPage";
472
-
473
- const routes = [
474
- {
475
- path: "/",
476
- element: <HomePage />,
477
- title: "Home",
478
- description: "Welcome to my website!",
479
- },
480
- {
481
- path: "/about/",
482
- element: <AboutPage />,
483
- title: "About",
484
- description: "Learn more about us.",
485
- },
486
- {
487
- path: "/contact/",
488
- element: <ContactPage />,
489
- title: "Contact",
490
- description: "Get in touch with us.",
491
- },
492
- ];
493
-
494
570
  const App = () => (
495
571
  <main>
496
572
  <nav>
@@ -498,10 +574,37 @@ const App = () => (
498
574
  </nav>
499
575
 
500
576
  <Routerino
501
- title="Foo.com"
502
- routes={routes}
577
+ title="Example.com"
503
578
  notFoundTitle="Sorry, but this page does not exist."
504
579
  errorTitle="Yikes! Something went wrong."
580
+ routes={[
581
+ {
582
+ path: "/",
583
+ element: <p>Welcome to Home</p>,
584
+ title: "Home",
585
+ description: "Welcome to my website!",
586
+ },
587
+ {
588
+ path: "/about/",
589
+ element: <p>About us...</p>,
590
+ title: "About",
591
+ description: "Learn more about us.",
592
+ },
593
+ {
594
+ path: "/contact/",
595
+ element: (
596
+ <div>
597
+ <h1>Contact Us</h1>
598
+ <p>
599
+ Please <a href="mailto:user@example.com">send us an email</a> at
600
+ user@example.com
601
+ </p>
602
+ </div>
603
+ ),
604
+ title: "Contact",
605
+ description: "Get in touch with us.",
606
+ },
607
+ ]}
505
608
  />
506
609
 
507
610
  <footer>
@@ -513,72 +616,49 @@ const App = () => (
513
616
  </main>
514
617
  );
515
618
 
516
- export default App;
619
+ render(<App />, document.getElementById("root"));
517
620
  ```
518
621
 
519
- ### Full React Example
622
+ ## ErrorBoundary Component
623
+
624
+ Routerino exports an `ErrorBoundary` component that you can use in your own applications to catch and handle React component errors gracefully. Fun fact: error boundary components are one of the last cases that still require using a React Class! Since this library aims to include everything you need to build a multiple page React SPA, and enable users to be able to know which component had an issue without confusing it with a Routerino bug.
520
625
 
521
- This example includes the full React configuration. It might take the place of `src/main.jsx` or an `index.js` file. Also suitable for use in a code-pen.
626
+ ### Import
522
627
 
523
628
  ```jsx
524
- import React from "react";
525
- import { render } from "react-dom";
526
- import Routerino from "routerino";
629
+ import { ErrorBoundary } from "routerino";
630
+ ```
527
631
 
528
- const title = "Example.com";
529
- const routes = [
530
- {
531
- path: "/",
532
- element: <p>Welcome to Home</p>,
533
- title: "Home",
534
- description: "Welcome to my website!",
535
- },
536
- {
537
- path: "/about/",
538
- element: <p>About us...</p>,
539
- title: "About",
540
- description: "Learn more about us.",
541
- },
542
- {
543
- path: "/contact/",
544
- element: (
545
- <div>
546
- <h1>Contact Us</h1>
547
- <p>
548
- Please <a href="mailto:user@example.com">send us an email</a> at
549
- user@example.com
550
- </p>
551
- </div>
552
- ),
553
- title: "Contact",
554
- description: "Get in touch with us.",
555
- },
556
- ];
632
+ ### Usage
557
633
 
558
- const App = () => (
559
- <main>
560
- <nav>
561
- <a href="/">Home</a>
562
- </nav>
634
+ ```jsx
635
+ <ErrorBoundary
636
+ fallback={<div>Something went wrong. Please try again.</div>}
637
+ errorTitleString="Error | My Application"
638
+ usePrerenderTags={true}
639
+ >
640
+ <MyComponent />
641
+ </ErrorBoundary>
642
+ ```
563
643
 
564
- <Routerino
565
- {...{
566
- title,
567
- routes,
568
- }}
569
- />
644
+ ### Props
570
645
 
571
- <footer>
572
- <p>
573
- Learn more <a href="/about/">about us</a> or{" "}
574
- <a href="/contact/">contact us</a> today.
575
- </p>
576
- </footer>
577
- </main>
578
- );
646
+ | Prop | Type | Required | Description |
647
+ | ------------------ | ----------- | -------- | ---------------------------------------------------- |
648
+ | `children` | `ReactNode` | No | The child components to render when there's no error |
649
+ | `fallback` | `ReactNode` | No | The UI to display when an error is caught |
650
+ | `errorTitleString` | `string` | Yes | The document title to set when an error occurs |
651
+ | `usePrerenderTags` | `boolean` | No | Whether to set prerender meta tag (status code 500) |
579
652
 
580
- render(<App />, document.getElementById("root"));
581
- ```
653
+ ### Features
654
+
655
+ - Catches JavaScript errors in child component tree
656
+ - Displays fallback UI instead of white screen
657
+ - Sets document title on error
658
+ - Logs errors to console for debugging
659
+ - Optionally sets prerender status code for SEO
660
+
661
+ This is the same error boundary used internally by Routerino to protect your route components from crashing the entire application.
582
662
 
583
663
  ## Vendoring Routerino
584
664
 
@@ -617,7 +697,7 @@ By vendoring Routerino, you have full control over the code and can make any nec
617
697
 
618
698
  ## Additional Resources
619
699
 
620
- There is a lot of information on SEO and social previews. Here are some sources for further reading on best-practices.
700
+ Here are some sources for further reading on SEO best-practices.
621
701
 
622
702
  - [Apple's best practices for link previews](https://developer.apple.com/library/archive/technotes/tn2444/_index.html)
623
703
  - [Use Open Graph tags](https://ahrefs.com/blog/open-graph-meta-tags/)
@@ -0,0 +1,270 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "fs";
4
+ import path from "path";
5
+
6
+ // Parse command line arguments
7
+ const args = process.argv.slice(2).reduce((acc, arg) => {
8
+ const [key, value] = arg.split("=");
9
+ acc[key] = value;
10
+ return acc;
11
+ }, {});
12
+
13
+ const {
14
+ routesFile,
15
+ outputDir = "./dist",
16
+ template = "./index.html",
17
+ baseUrl = "",
18
+ } = args;
19
+
20
+ if (!routesFile) {
21
+ console.error(
22
+ "Usage: node build-static.js routesFile=./src/routes.js outputDir=./dist template=./index.html baseUrl=https://example.com"
23
+ );
24
+ process.exit(1);
25
+ }
26
+
27
+ async function buildStaticSite() {
28
+ try {
29
+ console.log("🏗️ Building static site...\n");
30
+
31
+ // Check if routes file exists
32
+ const routesPath = path.resolve(routesFile);
33
+ if (!fs.existsSync(routesPath)) {
34
+ throw new Error(`Routes file not found: ${routesPath}`);
35
+ }
36
+
37
+ let routes;
38
+
39
+ // Try to import as a module first (for .js/.mjs files)
40
+ const ext = path.extname(routesPath);
41
+ if (ext === ".js" || ext === ".mjs" || ext === ".cjs") {
42
+ try {
43
+ const routesModule = await import(routesPath);
44
+ routes = routesModule.default || routesModule.routes;
45
+ } catch {
46
+ console.warn(
47
+ `⚠️ Could not import routes file as module, falling back to regex parsing`
48
+ );
49
+ routes = parseRoutesFromFile(routesPath);
50
+ }
51
+ } else {
52
+ // For JSX/TSX files, use regex parsing like build-sitemap does
53
+ routes = parseRoutesFromFile(routesPath);
54
+ }
55
+
56
+ if (!Array.isArray(routes)) {
57
+ throw new Error("Routes must be an array");
58
+ }
59
+
60
+ // Read the HTML template
61
+ const templatePath = path.resolve(template);
62
+ if (!fs.existsSync(templatePath)) {
63
+ throw new Error(`Template file not found: ${templatePath}`);
64
+ }
65
+
66
+ const templateHtml = fs.readFileSync(templatePath, "utf-8");
67
+
68
+ // Create output directory
69
+ const outputPath = path.resolve(outputDir);
70
+ if (!fs.existsSync(outputPath)) {
71
+ fs.mkdirSync(outputPath, { recursive: true });
72
+ }
73
+
74
+ // Process each route
75
+ let generatedCount = 0;
76
+
77
+ for (const route of routes) {
78
+ // Skip dynamic routes (with parameters)
79
+ if (route.path.includes(":")) {
80
+ console.log(`⏭️ Skipping dynamic route: ${route.path}`);
81
+ continue;
82
+ }
83
+
84
+ // Generate HTML for this route
85
+ const html = generateHtmlForRoute(route, templateHtml, baseUrl);
86
+
87
+ // Determine output file path
88
+ const routePath = route.path === "/" ? "/index" : route.path;
89
+ const filePath = path.join(outputPath, `${routePath}.html`);
90
+
91
+ // Create directory if needed
92
+ const fileDir = path.dirname(filePath);
93
+ if (!fs.existsSync(fileDir)) {
94
+ fs.mkdirSync(fileDir, { recursive: true });
95
+ }
96
+
97
+ // Write the HTML file
98
+ fs.writeFileSync(filePath, html);
99
+ console.log(`✅ Generated: ${filePath}`);
100
+ generatedCount++;
101
+ }
102
+
103
+ // Generate 404.html
104
+ // Create a default 404 route for proper meta tags
105
+ const notFoundRoute = {
106
+ path: "/404",
107
+ title: "404 - Page Not Found",
108
+ description: "The page you are looking for could not be found.",
109
+ };
110
+
111
+ // Generate 404.html with proper meta tags
112
+ // The actual notFoundTemplate component will be rendered client-side
113
+ const notFoundHtml = generateHtmlForRoute(
114
+ notFoundRoute,
115
+ templateHtml,
116
+ baseUrl
117
+ );
118
+ const notFoundPath = path.join(outputPath, "404.html");
119
+ fs.writeFileSync(notFoundPath, notFoundHtml);
120
+ console.log(`✅ Generated: ${notFoundPath}`);
121
+
122
+ console.log(
123
+ `\n🎉 Generated ${generatedCount + 1} static HTML files in ${outputDir}`
124
+ );
125
+ } catch (error) {
126
+ console.error("❌ Build failed:", error.message);
127
+ process.exit(1);
128
+ }
129
+ }
130
+
131
+ function generateHtmlForRoute(route, templateHtml, baseUrl) {
132
+ let html = templateHtml;
133
+
134
+ // Update title
135
+ if (route.title) {
136
+ html = html.replace(
137
+ /<title>.*?<\/title>/,
138
+ `<title>${escapeHtml(route.title)}</title>`
139
+ );
140
+ }
141
+
142
+ // Add/update meta tags
143
+ const metaTags = [];
144
+
145
+ if (route.description) {
146
+ metaTags.push(
147
+ `<meta name="description" content="${escapeHtml(route.description)}">`
148
+ );
149
+ metaTags.push(
150
+ `<meta property="og:description" content="${escapeHtml(route.description)}">`
151
+ );
152
+ }
153
+
154
+ if (route.title) {
155
+ metaTags.push(
156
+ `<meta property="og:title" content="${escapeHtml(route.title)}">`
157
+ );
158
+ }
159
+
160
+ metaTags.push(`<meta property="og:type" content="website">`);
161
+
162
+ if (baseUrl) {
163
+ metaTags.push(`<meta property="og:url" content="${baseUrl}${route.path}">`);
164
+ }
165
+
166
+ if (route.imageUrl) {
167
+ metaTags.push(
168
+ `<meta property="og:image" content="${escapeHtml(route.imageUrl)}">`
169
+ );
170
+ }
171
+
172
+ // Add custom tags
173
+ if (route.tags) {
174
+ route.tags.forEach((tag) => {
175
+ const attrs = Object.entries(tag)
176
+ .map(([key, value]) => `${key}="${escapeHtml(value)}"`)
177
+ .join(" ");
178
+ metaTags.push(`<meta ${attrs}>`);
179
+ });
180
+ }
181
+
182
+ // Insert meta tags before closing </head>
183
+ const metaTagsHtml = metaTags.join("\n ");
184
+ html = html.replace("</head>", ` ${metaTagsHtml}\n</head>`);
185
+
186
+ // Add route info as data attribute for client-side hydration
187
+ html = html.replace(
188
+ '<div id="root">',
189
+ `<div id="root" data-route="${escapeHtml(route.path)}">`
190
+ );
191
+
192
+ return html;
193
+ }
194
+
195
+ function escapeHtml(str) {
196
+ if (typeof str !== "string") return "";
197
+ return str
198
+ .replace(/&/g, "&amp;")
199
+ .replace(/</g, "&lt;")
200
+ .replace(/>/g, "&gt;")
201
+ .replace(/"/g, "&quot;")
202
+ .replace(/'/g, "&#39;");
203
+ }
204
+
205
+ function parseRoutesFromFile(filePath) {
206
+ // Read file contents and strip comments
207
+ const fileContent = fs
208
+ .readFileSync(filePath, "utf-8")
209
+ .replace(/\/\/.*|\/\*[\s\S]*?\*\//g, "");
210
+
211
+ // Find routes array in the file
212
+ // Matches: routes = [...], Routes = [...], export default [...], routes: [...], etc.
213
+ const arrayMatch = fileContent.match(
214
+ /(?:[rR]outes\s*[=:]\s*\{?\s*|\bexport\s+default\s+)(\[[\s\S]*?\])/
215
+ );
216
+
217
+ if (!arrayMatch || !arrayMatch[1]) {
218
+ throw new Error("Could not find routes array in file");
219
+ }
220
+
221
+ const routesArrayString = arrayMatch[1];
222
+
223
+ // Parse routes from the array string
224
+ // This is a simplified parser that extracts route objects
225
+ const routes = [];
226
+
227
+ // Match route objects: { path: "...", title: "...", ... }
228
+ const routeMatches = routesArrayString.matchAll(/\{([^{}]*)\}/g);
229
+
230
+ for (const match of routeMatches) {
231
+ const routeContent = match[1];
232
+ const route = {};
233
+
234
+ // Extract path (required)
235
+ const pathMatch = routeContent.match(/path\s*:\s*(["'`])(.*?)\1/);
236
+ if (pathMatch) {
237
+ route.path = pathMatch[2];
238
+ } else {
239
+ continue; // Skip routes without paths
240
+ }
241
+
242
+ // Extract title
243
+ const titleMatch = routeContent.match(/title\s*:\s*(["'`])(.*?)\1/);
244
+ if (titleMatch) {
245
+ route.title = titleMatch[2];
246
+ }
247
+
248
+ // Extract description
249
+ const descMatch = routeContent.match(/description\s*:\s*(["'`])(.*?)\1/);
250
+ if (descMatch) {
251
+ route.description = descMatch[2];
252
+ }
253
+
254
+ // Extract imageUrl
255
+ const imageMatch = routeContent.match(/imageUrl\s*:\s*(["'`])(.*?)\1/);
256
+ if (imageMatch) {
257
+ route.imageUrl = imageMatch[2];
258
+ }
259
+
260
+ routes.push(route);
261
+ }
262
+
263
+ console.log(
264
+ `📝 Parsed ${routes.length} routes from ${path.basename(filePath)}`
265
+ );
266
+ return routes;
267
+ }
268
+
269
+ // Run the build
270
+ buildStaticSite();
package/dist/routerino.js CHANGED
@@ -1,62 +1,102 @@
1
- import { jsx as u, jsxs as H, Fragment as N } from "react/jsx-runtime";
2
- import { useState as q, useEffect as A, cloneElement as C } from "react";
3
- import e from "prop-types";
4
- function r({ tag: s = "meta", soft: h = !1, ...d }) {
5
- const l = Object.keys(d);
6
- if (l.length < 1)
1
+ import { jsx as m, jsxs as F, Fragment as H } from "react/jsx-runtime";
2
+ import { Component as N, useState as O, useEffect as j, cloneElement as M } from "react";
3
+ import t from "prop-types";
4
+ function n({ tag: l = "meta", soft: d = !1, ...p }) {
5
+ const c = Object.keys(p);
6
+ if (c.length < 1)
7
7
  return console.error(
8
- `updateHeadTag() received no attributes to set for ${s} tag`
8
+ `updateHeadTag() received no attributes to set for ${l} tag`
9
9
  );
10
- let c = null;
11
- for (let i = 0; i < l.length && (l[i] !== "content" && (c = document.querySelector(
12
- `${s}[${l[i]}='${d[l[i]]}']`
13
- )), !c); i++)
10
+ let u = null;
11
+ for (let s = 0; s < c.length && (c[s] !== "content" && (u = document.querySelector(
12
+ `${l}[${c[s]}='${p[c[s]]}']`
13
+ )), !u); s++)
14
14
  ;
15
- !c && !h && (c = document.createElement(s)), l.forEach((i) => c.setAttribute(i, d[i])), document.querySelector("head").appendChild(c);
15
+ !u && !d && (u = document.createElement(l)), c.forEach((s) => u.setAttribute(s, p[s])), document.querySelector("head").appendChild(u);
16
16
  }
17
- function M({ routePattern: s, currentRoute: h }) {
18
- let d = {}, l = s.split("/"), c = h.split("/");
19
- return l.forEach((i, g) => {
20
- i.startsWith(":") && (d[i.slice(1)] = c[g]);
21
- }), d;
17
+ function z({ routePattern: l, currentRoute: d }) {
18
+ let p = {}, c = l.split("/"), u = d.split("/");
19
+ return c.forEach((s, g) => {
20
+ s.startsWith(":") && (p[s.slice(1)] = u[g]);
21
+ }), p;
22
22
  }
23
- function O({
24
- routes: s = [
23
+ class A extends N {
24
+ constructor(d) {
25
+ super(d), this.state = { hasError: !1 };
26
+ }
27
+ static getDerivedStateFromError() {
28
+ return { hasError: !0 };
29
+ }
30
+ componentDidCatch(d, p) {
31
+ console.group("🚨 Routerino Error Boundary Caught an Error"), console.error("Error:", d), console.error("Component Stack:", p.componentStack), this.props.routePath && console.error("Failed Route:", this.props.routePath), console.error("Error occurred at:", (/* @__PURE__ */ new Date()).toISOString()), console.groupEnd(), document.title = this.props.errorTitleString, this.props.usePrerenderTags && n({ name: "prerender-status-code", content: "500" });
32
+ }
33
+ render() {
34
+ return this.state.hasError ? this.props.fallback : this.props.children;
35
+ }
36
+ }
37
+ A.propTypes = {
38
+ /** The child components to render when there's no error */
39
+ children: t.node,
40
+ /** The fallback UI to display when an error is caught */
41
+ fallback: t.node,
42
+ /** The document title to set when an error occurs */
43
+ errorTitleString: t.string.isRequired,
44
+ /** Whether to set prerender meta tags (status code 500) on error */
45
+ usePrerenderTags: t.bool,
46
+ /** The current route path for better error context (optional) */
47
+ routePath: t.string
48
+ };
49
+ function I({
50
+ routes: l = [
25
51
  {
26
52
  path: "/",
27
- element: /* @__PURE__ */ u("p", { children: "This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined." }),
53
+ element: /* @__PURE__ */ m("p", { children: "This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined." }),
28
54
  title: "Routerino default route example",
29
55
  description: "The default route example description.",
30
56
  tags: [{ property: "og:locale", content: "en_US" }]
31
57
  }
32
58
  ],
33
- notFoundTemplate: h = /* @__PURE__ */ H(N, { children: [
34
- /* @__PURE__ */ u("p", { children: "No page found for this URL. [404]" }),
35
- /* @__PURE__ */ u("p", { children: /* @__PURE__ */ u("a", { href: "/", children: "Home" }) })
59
+ notFoundTemplate: d = /* @__PURE__ */ F(H, { children: [
60
+ /* @__PURE__ */ m("p", { children: "No page found for this URL. [404]" }),
61
+ /* @__PURE__ */ m("p", { children: /* @__PURE__ */ m("a", { href: "/", children: "Home" }) })
36
62
  ] }),
37
- notFoundTitle: d = "Page not found [404]",
38
- errorTemplate: l = /* @__PURE__ */ H(N, { children: [
39
- /* @__PURE__ */ u("p", { children: "Page failed to load. [500]" }),
40
- /* @__PURE__ */ u("p", { children: /* @__PURE__ */ u("a", { href: "/", children: "Home" }) })
63
+ notFoundTitle: p = "Page not found [404]",
64
+ errorTemplate: c = /* @__PURE__ */ F(H, { children: [
65
+ /* @__PURE__ */ m("p", { children: "Page failed to load. [500]" }),
66
+ /* @__PURE__ */ m("p", { children: /* @__PURE__ */ m("a", { href: "/", children: "Home" }) })
41
67
  ] }),
42
- errorTitle: c = "Page error [500]",
43
- useTrailingSlash: i = !0,
68
+ errorTitle: u = "Page error [500]",
69
+ useTrailingSlash: s = !0,
44
70
  usePrerenderTags: g = !0,
45
- title: w = "",
46
- separator: y = " | ",
47
- titlePrefix: R = "",
48
- titlePostfix: b = "",
49
- imageUrl: k = null,
71
+ title: E = "",
72
+ separator: S = " | ",
73
+ titlePrefix: y = "",
74
+ titlePostfix: $ = "",
75
+ imageUrl: b = null,
50
76
  touchIconUrl: P = null,
51
77
  debug: f = !1
52
78
  }) {
53
- var S, v, x;
79
+ var v, x, U;
80
+ const T = `${y}${u}${$ || `${S}${E}`}`, k = `${y}${p}${$ || `${S}${E}`}`;
54
81
  try {
55
- const [E, T] = q(window.location.href);
56
- A(() => {
57
- const t = (m) => {
82
+ if (f || window.location.host === "localhost" || window.location.host.includes("localhost:")) {
83
+ y !== "" && console.warn(
84
+ "Routerino: titlePrefix is deprecated and will be removed in v2.0. Please migrate to the title and separator props instead."
85
+ ), $ !== "" && console.warn(
86
+ "Routerino: titlePostfix is deprecated and will be removed in v2.0. Please migrate to the title and separator props instead."
87
+ );
88
+ const e = l.map((h) => h.path), i = e.filter(
89
+ (h, a) => e.indexOf(h) !== a
90
+ );
91
+ i.length > 0 && (console.warn("⚠️ Routerino: Duplicate route paths detected:", [
92
+ ...new Set(i)
93
+ ]), console.warn("The first matching route will be used"));
94
+ }
95
+ const [R, q] = O(window.location.href);
96
+ j(() => {
97
+ const e = (h) => {
58
98
  f && console.debug("click occurred");
59
- let a = m.target;
99
+ let a = h.target;
60
100
  for (; a.tagName !== "A" && a.parentElement; )
61
101
  a = a.parentElement;
62
102
  if (a.tagName !== "A") {
@@ -64,104 +104,136 @@ function O({
64
104
  return;
65
105
  }
66
106
  f && console.debug(`click target ${a}`);
67
- let $ = new URL(a);
68
- f && console.debug(`targetUrl: ${$}, current: ${window.location}`), window.location.origin === $.origin ? (f && console.debug(
107
+ let w = new URL(a);
108
+ f && console.debug(`targetUrl: ${w}, current: ${window.location}`), window.location.origin === w.origin ? (f && console.debug(
69
109
  "target link is same origin, push-state transitioning"
70
- ), m.preventDefault(), a.href !== window.location.href && (T(a.href), window.history.pushState({}, "", a.href)), window.scrollTo({
110
+ ), h.preventDefault(), a.href !== window.location.href && (q(a.href), window.history.pushState({}, "", a.href)), window.scrollTo({
71
111
  top: 0,
72
112
  behavior: "auto"
73
113
  })) : f && console.debug(
74
114
  "target link does not share an origin, standard link handling applies"
75
115
  );
76
116
  };
77
- document.addEventListener("click", t);
78
- const p = () => {
79
- T(window.location.href);
117
+ document.addEventListener("click", e);
118
+ const i = () => {
119
+ q(window.location.href);
80
120
  };
81
- return window.addEventListener("popstate", p), () => {
82
- document.removeEventListener("click", t), window.removeEventListener("popstate", p);
121
+ return window.addEventListener("popstate", i), () => {
122
+ document.removeEventListener("click", e), window.removeEventListener("popstate", i);
83
123
  };
84
- }, [E]);
85
- let o = ((S = window.location) == null ? void 0 : S.pathname) ?? "/";
86
- (o === "/index.html" || o === "") && (o = "/");
87
- const U = s.find((t) => t.path === o), L = s.find(
88
- (t) => `${t.path}/` === o || t.path === `${o}/`
89
- ), W = s.find((t) => {
90
- const p = t.path.endsWith("/") ? t.path.slice(0, -1) : t.path, m = o.endsWith("/") ? o.slice(0, -1) : o, a = p.split("/").filter(Boolean), $ = m.split("/").filter(Boolean);
91
- return a.length !== $.length ? !1 : a.every((B, j) => B.startsWith(":") ? !0 : B === $[j]);
92
- }), n = U ?? L ?? W;
93
- if (f && console.debug({ match: n, exactMatch: U, addSlashMatch: L, paramsMatch: W }), !n)
94
- return console.error(`No matching route found for ${o}`), document.title = `${R}${d}${b || `${y}${w}`}`, g && r({ name: "prerender-status-code", content: "404" }), h;
95
- if (n.title) {
96
- const t = `${n.titlePrefix ?? R}${n.title}${n.titlePostfix || b || `${y}${w}`}`;
97
- document.title = t, (v = n.tags) != null && v.find(({ property: p }) => p === "og:title") || r({
124
+ }, [R]);
125
+ let r = ((v = window.location) == null ? void 0 : v.pathname) ?? "/";
126
+ (r === "/index.html" || r === "") && (r = "/");
127
+ const B = l.find((e) => e.path === r), L = l.find(
128
+ (e) => `${e.path}/` === r || e.path === `${r}/`
129
+ ), C = l.find((e) => {
130
+ const i = e.path.endsWith("/") ? e.path.slice(0, -1) : e.path, h = r.endsWith("/") ? r.slice(0, -1) : r, a = i.split("/").filter(Boolean), w = h.split("/").filter(Boolean);
131
+ return a.length !== w.length ? !1 : a.every((W, D) => W.startsWith(":") ? !0 : W === w[D]);
132
+ }), o = B ?? L ?? C;
133
+ if (f && console.debug({ match: o, exactMatch: B, addSlashMatch: L, paramsMatch: C }), !o)
134
+ return (f || window.location.host === "localhost" || window.location.host.includes("localhost:")) && (console.group("⚠️ Routerino 404 - No matching route"), console.warn(`Requested path: ${r}`), console.warn(
135
+ "Available routes:",
136
+ l.map((e) => e.path)
137
+ ), console.groupEnd()), document.title = k, g && n({ name: "prerender-status-code", content: "404" }), d;
138
+ if (g) {
139
+ const e = document.querySelector(
140
+ 'meta[name="prerender-status-code"]'
141
+ );
142
+ e && e.remove();
143
+ const i = document.querySelector(
144
+ 'meta[name="prerender-header"]'
145
+ );
146
+ i && i.remove();
147
+ }
148
+ if (o.title) {
149
+ const e = `${o.titlePrefix ?? y}${o.title}${o.titlePostfix || $ || `${S}${E}`}`;
150
+ document.title = e, (x = o.tags) != null && x.find(({ property: i }) => i === "og:title") || n({
98
151
  property: "og:title",
99
- content: t
152
+ content: e
100
153
  });
101
154
  }
102
- if (n.description && (r({ name: "description", content: n.description }), (x = n.tags) != null && x.find(({ property: t }) => t === "og:description") || r({
155
+ if (o.description && (n({ name: "description", content: o.description }), (U = o.tags) != null && U.find(({ property: e }) => e === "og:description") || n({
103
156
  property: "og:description",
104
- content: n.description
105
- })), (k || n.imageUrl) && r({
157
+ content: o.description
158
+ })), (b || o.imageUrl) && n({
106
159
  property: "og:image",
107
- content: n.imageUrl ?? k
108
- }), P && r({
160
+ content: o.imageUrl ?? b
161
+ }), P && n({
109
162
  tag: "link",
110
163
  rel: "apple-touch-icon",
111
164
  href: P
112
- }), g && o !== "/" && (i && !o.endsWith("/") ? (r({ name: "prerender-status-code", content: "301" }), r({
165
+ }), g && r !== "/" && (s && !r.endsWith("/") ? (n({ name: "prerender-status-code", content: "301" }), n({
113
166
  name: "prerender-header",
114
167
  content: `Location: ${window.location.href}/`
115
- })) : !i && o.endsWith("/") && (r({ name: "prerender-status-code", content: "301" }), r({
168
+ })) : !s && r.endsWith("/") && (n({ name: "prerender-status-code", content: "301" }), n({
116
169
  name: "prerender-header",
117
170
  content: `Location: ${window.location.href.slice(0, -1)}`
118
- }))), n.tags && n.tags.length ? (n.tags.find(({ property: t }) => t === "og:type") || r({ property: "og:type", content: "website" }), n.tags.forEach((t) => r(t))) : r({ property: "og:type", content: "website" }), n.element) {
119
- const t = M({
120
- routePattern: n.path,
121
- currentRoute: o
122
- }), p = {
123
- currentRoute: o,
124
- params: t,
125
- routePattern: n.path,
126
- updateHeadTag: r
127
- };
128
- return C(n.element, {
171
+ }))), o.tags && o.tags.length ? (o.tags.find(({ property: e }) => e === "og:type") || n({ property: "og:type", content: "website" }), o.tags.forEach((e) => n(e))) : n({ property: "og:type", content: "website" }), o.element) {
172
+ const e = z({
173
+ routePattern: o.path,
174
+ currentRoute: r
175
+ }), i = {
176
+ currentRoute: r,
177
+ params: e,
178
+ routePattern: o.path,
179
+ updateHeadTag: n
180
+ }, h = M(o.element, {
129
181
  // we allow access via both uppercase and lowercase
130
- routerino: p,
131
- Routerino: p
182
+ routerino: i,
183
+ Routerino: i
132
184
  });
185
+ return /* @__PURE__ */ m(
186
+ A,
187
+ {
188
+ fallback: c,
189
+ errorTitleString: T,
190
+ usePrerenderTags: g,
191
+ routePath: r,
192
+ children: h
193
+ }
194
+ );
133
195
  }
134
- return console.error(`No route found for ${o}`), document.title = `${R}${d}${b || `${y}${w}`}`, g && r({ name: "prerender-status-code", content: "404" }), h;
135
- } catch (E) {
136
- return console.error(`Routerino error: ${E}`), g && r({ name: "prerender-status-code", content: "500" }), document.title = `${R}${c}${b || `${y}${w}`}`, l;
196
+ return console.error(`No route found for ${r}`), document.title = k, g && n({ name: "prerender-status-code", content: "404" }), d;
197
+ } catch (R) {
198
+ return console.group("💥 Routerino Fatal Error"), console.error(
199
+ "An error occurred in the router itself (not in a route component)"
200
+ ), console.error("Error:", R), console.error(
201
+ "This typically means an issue with route configuration or router setup"
202
+ ), console.groupEnd(), g && n({ name: "prerender-status-code", content: "500" }), document.title = T, c;
137
203
  }
138
204
  }
139
- const z = e.exact({
140
- path: e.string.isRequired,
141
- element: e.element.isRequired,
142
- title: e.string,
143
- description: e.string,
144
- tags: e.arrayOf(e.object),
145
- titlePrefix: e.string,
146
- titlePostfix: e.string,
147
- imageUrl: e.string
205
+ const K = t.exact({
206
+ path: t.string.isRequired,
207
+ element: t.element.isRequired,
208
+ title: t.string,
209
+ description: t.string,
210
+ tags: t.arrayOf(t.object),
211
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
212
+ titlePrefix: t.string,
213
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
214
+ titlePostfix: t.string,
215
+ imageUrl: t.string
148
216
  });
149
- O.propTypes = {
150
- routes: e.arrayOf(z),
151
- title: e.string,
152
- separator: e.string,
153
- notFoundTemplate: e.element,
154
- notFoundTitle: e.string,
155
- errorTemplate: e.element,
156
- errorTitle: e.string,
157
- useTrailingSlash: e.bool,
158
- usePrerenderTags: e.bool,
159
- titlePrefix: e.string,
160
- titlePostfix: e.string,
161
- imageUrl: e.string,
162
- touchIconUrl: e.string,
163
- debug: e.bool
217
+ I.propTypes = {
218
+ routes: t.arrayOf(K),
219
+ title: t.string,
220
+ separator: t.string,
221
+ notFoundTemplate: t.element,
222
+ notFoundTitle: t.string,
223
+ errorTemplate: t.element,
224
+ errorTitle: t.string,
225
+ useTrailingSlash: t.bool,
226
+ usePrerenderTags: t.bool,
227
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
228
+ titlePrefix: t.string,
229
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
230
+ titlePostfix: t.string,
231
+ imageUrl: t.string,
232
+ touchIconUrl: t.string,
233
+ debug: t.bool
164
234
  };
165
235
  export {
166
- O as default
236
+ A as ErrorBoundary,
237
+ I as default,
238
+ n as updateHeadTag
167
239
  };
@@ -1 +1 @@
1
- (function(o,h){typeof exports=="object"&&typeof module<"u"?module.exports=h(require("react/jsx-runtime"),require("react"),require("prop-types")):typeof define=="function"&&define.amd?define(["react/jsx-runtime","react","prop-types"],h):(o=typeof globalThis<"u"?globalThis:o||self,o.routerino=h(o["react/jsx-runtime"],o.React,o.PropTypes))})(this,function(o,h,e){"use strict";function r({tag:d="meta",soft:m=!1,...f}){const l=Object.keys(f);if(l.length<1)return console.error(`updateHeadTag() received no attributes to set for ${d} tag`);let s=null;for(let a=0;a<l.length&&(l[a]!=="content"&&(s=document.querySelector(`${d}[${l[a]}='${f[l[a]]}']`)),!s);a++);!s&&!m&&(s=document.createElement(d)),l.forEach(a=>s.setAttribute(a,f[a])),document.querySelector("head").appendChild(s)}function A({routePattern:d,currentRoute:m}){let f={},l=d.split("/"),s=m.split("/");return l.forEach((a,p)=>{a.startsWith(":")&&(f[a.slice(1)]=s[p])}),f}function v({routes:d=[{path:"/",element:o.jsx("p",{children:"This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined."}),title:"Routerino default route example",description:"The default route example description.",tags:[{property:"og:locale",content:"en_US"}]}],notFoundTemplate:m=o.jsxs(o.Fragment,{children:[o.jsx("p",{children:"No page found for this URL. [404]"}),o.jsx("p",{children:o.jsx("a",{href:"/",children:"Home"})})]}),notFoundTitle:f="Page not found [404]",errorTemplate:l=o.jsxs(o.Fragment,{children:[o.jsx("p",{children:"Page failed to load. [500]"}),o.jsx("p",{children:o.jsx("a",{href:"/",children:"Home"})})]}),errorTitle:s="Page error [500]",useTrailingSlash:a=!0,usePrerenderTags:p=!0,title:E="",separator:k=" | ",titlePrefix:b="",titlePostfix:S="",imageUrl:U=null,touchIconUrl:L=null,debug:g=!1}){var W,q,B;try{const[x,R]=h.useState(window.location.href);h.useEffect(()=>{const t=$=>{g&&console.debug("click occurred");let c=$.target;for(;c.tagName!=="A"&&c.parentElement;)c=c.parentElement;if(c.tagName!=="A"){g&&console.debug("no achor tag found during click");return}g&&console.debug(`click target ${c}`);let w=new URL(c);g&&console.debug(`targetUrl: ${w}, current: ${window.location}`),window.location.origin===w.origin?(g&&console.debug("target link is same origin, push-state transitioning"),$.preventDefault(),c.href!==window.location.href&&(R(c.href),window.history.pushState({},"",c.href)),window.scrollTo({top:0,behavior:"auto"})):g&&console.debug("target link does not share an origin, standard link handling applies")};document.addEventListener("click",t);const u=()=>{R(window.location.href)};return window.addEventListener("popstate",u),()=>{document.removeEventListener("click",t),window.removeEventListener("popstate",u)}},[x]);let i=((W=window.location)==null?void 0:W.pathname)??"/";(i==="/index.html"||i==="")&&(i="/");const j=d.find(t=>t.path===i),H=d.find(t=>`${t.path}/`===i||t.path===`${i}/`),N=d.find(t=>{const u=t.path.endsWith("/")?t.path.slice(0,-1):t.path,$=i.endsWith("/")?i.slice(0,-1):i,c=u.split("/").filter(Boolean),w=$.split("/").filter(Boolean);return c.length!==w.length?!1:c.every((y,M)=>y.startsWith(":")?!0:y===w[M])}),n=j??H??N;if(g&&console.debug({match:n,exactMatch:j,addSlashMatch:H,paramsMatch:N}),!n)return console.error(`No matching route found for ${i}`),document.title=`${b}${f}${S||`${k}${E}`}`,p&&r({name:"prerender-status-code",content:"404"}),m;if(n.title){const t=`${n.titlePrefix??b}${n.title}${n.titlePostfix||S||`${k}${E}`}`;document.title=t,(q=n.tags)!=null&&q.find(({property:u})=>u==="og:title")||r({property:"og:title",content:t})}if(n.description&&(r({name:"description",content:n.description}),(B=n.tags)!=null&&B.find(({property:t})=>t==="og:description")||r({property:"og:description",content:n.description})),(U||n.imageUrl)&&r({property:"og:image",content:n.imageUrl??U}),L&&r({tag:"link",rel:"apple-touch-icon",href:L}),p&&i!=="/"&&(a&&!i.endsWith("/")?(r({name:"prerender-status-code",content:"301"}),r({name:"prerender-header",content:`Location: ${window.location.href}/`})):!a&&i.endsWith("/")&&(r({name:"prerender-status-code",content:"301"}),r({name:"prerender-header",content:`Location: ${window.location.href.slice(0,-1)}`}))),n.tags&&n.tags.length?(n.tags.find(({property:t})=>t==="og:type")||r({property:"og:type",content:"website"}),n.tags.forEach(t=>r(t))):r({property:"og:type",content:"website"}),n.element){const t=A({routePattern:n.path,currentRoute:i}),u={currentRoute:i,params:t,routePattern:n.path,updateHeadTag:r};return h.cloneElement(n.element,{routerino:u,Routerino:u})}return console.error(`No route found for ${i}`),document.title=`${b}${f}${S||`${k}${E}`}`,p&&r({name:"prerender-status-code",content:"404"}),m}catch(x){return console.error(`Routerino error: ${x}`),p&&r({name:"prerender-status-code",content:"500"}),document.title=`${b}${s}${S||`${k}${E}`}`,l}}const C=e.exact({path:e.string.isRequired,element:e.element.isRequired,title:e.string,description:e.string,tags:e.arrayOf(e.object),titlePrefix:e.string,titlePostfix:e.string,imageUrl:e.string});return v.propTypes={routes:e.arrayOf(C),title:e.string,separator:e.string,notFoundTemplate:e.element,notFoundTitle:e.string,errorTemplate:e.element,errorTitle:e.string,useTrailingSlash:e.bool,usePrerenderTags:e.bool,titlePrefix:e.string,titlePostfix:e.string,imageUrl:e.string,touchIconUrl:e.string,debug:e.bool},v});
1
+ (function(d,i){typeof exports=="object"&&typeof module<"u"?i(exports,require("react/jsx-runtime"),require("react"),require("prop-types")):typeof define=="function"&&define.amd?define(["exports","react/jsx-runtime","react","prop-types"],i):(d=typeof globalThis<"u"?globalThis:d||self,i(d.routerino={},d["react/jsx-runtime"],d.React,d.PropTypes))})(this,function(d,i,$,t){"use strict";function r({tag:s="meta",soft:h=!1,...f}){const u=Object.keys(f);if(u.length<1)return console.error(`updateHeadTag() received no attributes to set for ${s} tag`);let p=null;for(let c=0;c<u.length&&(u[c]!=="content"&&(p=document.querySelector(`${s}[${u[c]}='${f[u[c]]}']`)),!p);c++);!p&&!h&&(p=document.createElement(s)),u.forEach(c=>p.setAttribute(c,f[c])),document.querySelector("head").appendChild(p)}function M({routePattern:s,currentRoute:h}){let f={},u=s.split("/"),p=h.split("/");return u.forEach((c,w)=>{c.startsWith(":")&&(f[c.slice(1)]=p[w])}),f}class v extends $.Component{constructor(h){super(h),this.state={hasError:!1}}static getDerivedStateFromError(){return{hasError:!0}}componentDidCatch(h,f){console.group("🚨 Routerino Error Boundary Caught an Error"),console.error("Error:",h),console.error("Component Stack:",f.componentStack),this.props.routePath&&console.error("Failed Route:",this.props.routePath),console.error("Error occurred at:",new Date().toISOString()),console.groupEnd(),document.title=this.props.errorTitleString,this.props.usePrerenderTags&&r({name:"prerender-status-code",content:"500"})}render(){return this.state.hasError?this.props.fallback:this.props.children}}v.propTypes={children:t.node,fallback:t.node,errorTitleString:t.string.isRequired,usePrerenderTags:t.bool,routePath:t.string};function q({routes:s=[{path:"/",element:i.jsx("p",{children:"This is the default route. Pass an array of routes to the Routerino component in order to configure your own pages. Each route is a dictionary with at least `path` and `element` defined."}),title:"Routerino default route example",description:"The default route example description.",tags:[{property:"og:locale",content:"en_US"}]}],notFoundTemplate:h=i.jsxs(i.Fragment,{children:[i.jsx("p",{children:"No page found for this URL. [404]"}),i.jsx("p",{children:i.jsx("a",{href:"/",children:"Home"})})]}),notFoundTitle:f="Page not found [404]",errorTemplate:u=i.jsxs(i.Fragment,{children:[i.jsx("p",{children:"Page failed to load. [500]"}),i.jsx("p",{children:i.jsx("a",{href:"/",children:"Home"})})]}),errorTitle:p="Page error [500]",useTrailingSlash:c=!0,usePrerenderTags:w=!0,title:k="",separator:R=" | ",titlePrefix:S="",titlePostfix:b="",imageUrl:B=null,touchIconUrl:U=null,debug:m=!1}){var C,F,H;const y=`${S}${p}${b||`${R}${k}`}`,L=`${S}${f}${b||`${R}${k}`}`;try{if(m||window.location.host==="localhost"||window.location.host.includes("localhost:")){S!==""&&console.warn("Routerino: titlePrefix is deprecated and will be removed in v2.0. Please migrate to the title and separator props instead."),b!==""&&console.warn("Routerino: titlePostfix is deprecated and will be removed in v2.0. Please migrate to the title and separator props instead.");const e=s.map(g=>g.path),a=e.filter((g,l)=>e.indexOf(g)!==l);a.length>0&&(console.warn("⚠️ Routerino: Duplicate route paths detected:",[...new Set(a)]),console.warn("The first matching route will be used"))}const[x,W]=$.useState(window.location.href);$.useEffect(()=>{const e=g=>{m&&console.debug("click occurred");let l=g.target;for(;l.tagName!=="A"&&l.parentElement;)l=l.parentElement;if(l.tagName!=="A"){m&&console.debug("no achor tag found during click");return}m&&console.debug(`click target ${l}`);let E=new URL(l);m&&console.debug(`targetUrl: ${E}, current: ${window.location}`),window.location.origin===E.origin?(m&&console.debug("target link is same origin, push-state transitioning"),g.preventDefault(),l.href!==window.location.href&&(W(l.href),window.history.pushState({},"",l.href)),window.scrollTo({top:0,behavior:"auto"})):m&&console.debug("target link does not share an origin, standard link handling applies")};document.addEventListener("click",e);const a=()=>{W(window.location.href)};return window.addEventListener("popstate",a),()=>{document.removeEventListener("click",e),window.removeEventListener("popstate",a)}},[x]);let n=((C=window.location)==null?void 0:C.pathname)??"/";(n==="/index.html"||n==="")&&(n="/");const j=s.find(e=>e.path===n),O=s.find(e=>`${e.path}/`===n||e.path===`${n}/`),A=s.find(e=>{const a=e.path.endsWith("/")?e.path.slice(0,-1):e.path,g=n.endsWith("/")?n.slice(0,-1):n,l=a.split("/").filter(Boolean),E=g.split("/").filter(Boolean);return l.length!==E.length?!1:l.every((D,_)=>D.startsWith(":")?!0:D===E[_])}),o=j??O??A;if(m&&console.debug({match:o,exactMatch:j,addSlashMatch:O,paramsMatch:A}),!o)return(m||window.location.host==="localhost"||window.location.host.includes("localhost:"))&&(console.group("⚠️ Routerino 404 - No matching route"),console.warn(`Requested path: ${n}`),console.warn("Available routes:",s.map(e=>e.path)),console.groupEnd()),document.title=L,w&&r({name:"prerender-status-code",content:"404"}),h;if(w){const e=document.querySelector('meta[name="prerender-status-code"]');e&&e.remove();const a=document.querySelector('meta[name="prerender-header"]');a&&a.remove()}if(o.title){const e=`${o.titlePrefix??S}${o.title}${o.titlePostfix||b||`${R}${k}`}`;document.title=e,(F=o.tags)!=null&&F.find(({property:a})=>a==="og:title")||r({property:"og:title",content:e})}if(o.description&&(r({name:"description",content:o.description}),(H=o.tags)!=null&&H.find(({property:e})=>e==="og:description")||r({property:"og:description",content:o.description})),(B||o.imageUrl)&&r({property:"og:image",content:o.imageUrl??B}),U&&r({tag:"link",rel:"apple-touch-icon",href:U}),w&&n!=="/"&&(c&&!n.endsWith("/")?(r({name:"prerender-status-code",content:"301"}),r({name:"prerender-header",content:`Location: ${window.location.href}/`})):!c&&n.endsWith("/")&&(r({name:"prerender-status-code",content:"301"}),r({name:"prerender-header",content:`Location: ${window.location.href.slice(0,-1)}`}))),o.tags&&o.tags.length?(o.tags.find(({property:e})=>e==="og:type")||r({property:"og:type",content:"website"}),o.tags.forEach(e=>r(e))):r({property:"og:type",content:"website"}),o.element){const e=M({routePattern:o.path,currentRoute:n}),a={currentRoute:n,params:e,routePattern:o.path,updateHeadTag:r},g=$.cloneElement(o.element,{routerino:a,Routerino:a});return i.jsx(v,{fallback:u,errorTitleString:y,usePrerenderTags:w,routePath:n,children:g})}return console.error(`No route found for ${n}`),document.title=L,w&&r({name:"prerender-status-code",content:"404"}),h}catch(x){return console.group("💥 Routerino Fatal Error"),console.error("An error occurred in the router itself (not in a route component)"),console.error("Error:",x),console.error("This typically means an issue with route configuration or router setup"),console.groupEnd(),w&&r({name:"prerender-status-code",content:"500"}),document.title=y,u}}const N=t.exact({path:t.string.isRequired,element:t.element.isRequired,title:t.string,description:t.string,tags:t.arrayOf(t.object),titlePrefix:t.string,titlePostfix:t.string,imageUrl:t.string});q.propTypes={routes:t.arrayOf(N),title:t.string,separator:t.string,notFoundTemplate:t.element,notFoundTitle:t.string,errorTemplate:t.element,errorTitle:t.string,useTrailingSlash:t.bool,usePrerenderTags:t.bool,titlePrefix:t.string,titlePostfix:t.string,imageUrl:t.string,touchIconUrl:t.string,debug:t.bool},d.ErrorBoundary=v,d.default=q,d.updateHeadTag=r,Object.defineProperties(d,{__esModule:{value:!0},[Symbol.toStringTag]:{value:"Module"}})});
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "routerino",
3
- "version": "1.1.9",
3
+ "version": "1.2.0",
4
4
  "description": "A lightweight, SEO-optimized React router for modern web applications",
5
5
  "repository": {
6
6
  "type": "git",
@@ -20,11 +20,15 @@
20
20
  "homepage": "https://github.com/nerds-with-keyboards/routerino#readme",
21
21
  "files": [
22
22
  "dist",
23
- "types"
23
+ "types",
24
+ "build-sitemap.js",
25
+ "build-static.js",
26
+ "prerender",
27
+ "docs/deployment"
24
28
  ],
25
29
  "bin": {
26
30
  "routerino-build-sitemap": "build-sitemap.js",
27
- "build-sitemap": "build-sitemap.js"
31
+ "routerino-build-static": "build-static.js"
28
32
  },
29
33
  "type": "module",
30
34
  "types": "types/routerino.d.ts",
@@ -36,31 +40,66 @@
36
40
  },
37
41
  "scripts": {
38
42
  "build": "vite build",
39
- "test": "echo TODO",
43
+ "build:static": "node build-static.js",
44
+ "test": "vitest",
45
+ "test:coverage": "vitest run --coverage",
46
+ "test:react-versions": "node test-react-versions.js",
47
+ "test:node-versions": "node test-node-versions.js",
40
48
  "prepublishOnly": "vite build",
41
- "lint": "eslint --fix . --max-warnings=0"
49
+ "lint": "eslint --fix . --max-warnings=0",
50
+ "lint:fix": "eslint --fix .",
51
+ "fix:entities": "find demo-prerender demo-static -name '*.jsx' -exec sed -i '' 's/\"/{\\\"}/g; s/'\"'\"'/{\\\"'\"'\"'}/g' {} +",
52
+ "format": "prettier --write .",
53
+ "format:check": "prettier --check .",
54
+ "prepare": "husky"
42
55
  },
43
56
  "devDependencies": {
44
57
  "@eslint/js": "^9.27.0",
58
+ "@testing-library/react": "^16.3.0",
59
+ "@testing-library/user-event": "^14.6.1",
45
60
  "@vitejs/plugin-react": "^4.5.0",
46
61
  "eslint": "^9.27.0",
47
62
  "eslint-plugin-react": "^7.37.5",
63
+ "express": "^4.18.2",
48
64
  "globals": "^16.1.0",
65
+ "husky": "^9.1.7",
66
+ "jsdom": "^26.1.0",
67
+ "lint-staged": "^16.1.2",
68
+ "node-fetch": "^3.3.2",
69
+ "prettier": "^3.6.2",
49
70
  "prop-types": "^15.8.1",
50
71
  "react": "^19.1.0",
51
72
  "react-dom": "^19.1.0",
52
- "vite": "^6.3.5"
73
+ "vite": "^6.3.5",
74
+ "vitest": "^3.2.4"
53
75
  },
54
76
  "peerDependencies": {
55
77
  "prop-types": "^15.0.0",
56
- "react": "^18.0.0 || ^19.0.0",
57
- "react-dom": "^18.0.0 || ^19.0.0"
78
+ "react": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^10.0.0",
79
+ "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0 || ^10.0.0"
80
+ },
81
+ "peerDependenciesMeta": {
82
+ "react": {
83
+ "optional": false
84
+ },
85
+ "react-dom": {
86
+ "optional": false
87
+ }
58
88
  },
59
89
  "engines": {
60
- "node": ">=16"
90
+ "node": ">=18"
61
91
  },
62
92
  "volta": {
63
93
  "node": "22.16.0",
64
94
  "npm": "10.9.2"
95
+ },
96
+ "lint-staged": {
97
+ "*.{js,jsx,mjs,cjs}": [
98
+ "eslint --fix --max-warnings=0 --no-warn-ignored",
99
+ "prettier --write"
100
+ ],
101
+ "*.{json,md,yml,yaml,css,html}": [
102
+ "prettier --write"
103
+ ]
65
104
  }
66
105
  }
@@ -26,7 +26,9 @@ export interface RouteConfig {
26
26
  title?: string;
27
27
  description?: string;
28
28
  tags?: HeadTag[];
29
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
29
30
  titlePrefix?: string;
31
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
30
32
  titlePostfix?: string;
31
33
  imageUrl?: string;
32
34
  }
@@ -41,13 +43,42 @@ export interface RouterinoProps {
41
43
  useTrailingSlash?: boolean;
42
44
  usePrerenderTags?: boolean;
43
45
  separator?: string;
46
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
44
47
  titlePrefix?: string;
48
+ /** @deprecated Use title and separator props instead. Will be removed in v2.0 */
45
49
  titlePostfix?: string;
46
50
  imageUrl?: string;
47
51
  touchIconUrl?: string;
48
52
  debug?: boolean;
49
53
  }
50
54
 
55
+ export interface ErrorBoundaryProps {
56
+ /** The child components to render when there's no error */
57
+ children?: React.ReactNode;
58
+ /** The fallback UI to display when an error is caught */
59
+ fallback?: React.ReactNode;
60
+ /** The document title to set when an error occurs */
61
+ errorTitleString: string;
62
+ /** Whether to set prerender meta tags (status code 500) on error */
63
+ usePrerenderTags?: boolean;
64
+ /** The current route path for better error context (optional) */
65
+ routePath?: string;
66
+ }
67
+
68
+ export interface ErrorBoundaryState {
69
+ hasError: boolean;
70
+ }
71
+
72
+ export class ErrorBoundary extends React.Component<
73
+ ErrorBoundaryProps,
74
+ ErrorBoundaryState
75
+ > {
76
+ constructor(props: ErrorBoundaryProps);
77
+ static getDerivedStateFromError(error: Error): ErrorBoundaryState;
78
+ componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void;
79
+ render(): React.ReactNode;
80
+ }
81
+
51
82
  declare function Routerino(props: RouterinoProps): JSX.Element;
52
83
 
53
84
  export default Routerino;