Showing preview only (214K chars total). Download the full file or copy to clipboard to get everything.
Repository: preactjs/preact-iso
Branch: main
Commit: f554b913f5a0
Files: 48
Total size: 200.4 KB
Directory structure:
gitextract_qae3wprk/
├── .editorconfig
├── .github/
│ ├── ISSUE_TEMPLATE/
│ │ ├── bug_report.md
│ │ └── feature_request.md
│ └── workflows/
│ └── ci.yml
├── .gitignore
├── CODE_OF_CONDUCT.md
├── LICENSE
├── README.md
├── jsconfig.json
├── package.json
├── polyglot-utils/
│ ├── .gitignore
│ ├── README.md
│ ├── go/
│ │ ├── README.md
│ │ ├── go.mod
│ │ ├── preact_iso_url_pattern.go
│ │ └── preact_iso_url_pattern_test.go
│ ├── php/
│ │ ├── README.md
│ │ ├── preact-iso-url-pattern.php
│ │ └── test_preact_iso_url_pattern.php
│ ├── python/
│ │ ├── README.md
│ │ ├── preact_iso_url_pattern.py
│ │ └── test_preact_iso_url_pattern.py
│ ├── ruby/
│ │ ├── README.md
│ │ ├── preact-iso-url-pattern.rb
│ │ └── test_preact_iso_url_pattern.rb
│ └── run_tests.sh
├── src/
│ ├── hydrate.d.ts
│ ├── hydrate.js
│ ├── index.d.ts
│ ├── index.js
│ ├── internal.d.ts
│ ├── lazy.d.ts
│ ├── lazy.js
│ ├── prerender.d.ts
│ ├── prerender.js
│ ├── router-navigation-api.d.ts
│ ├── router-navigation-api.js
│ ├── router.d.ts
│ └── router.js
├── test/
│ ├── lazy.test.js
│ ├── node/
│ │ ├── location-stub.test.js
│ │ ├── pattern-match.types.ts
│ │ ├── prerender.test.js
│ │ └── router-match.test.js
│ ├── router-navigation-api.test.js
│ ├── router.test.js
│ └── setup.js
└── web-test-runner.config.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[{.*rc,*.yml}]
indent_style = space
indent_size = 2
[package.json]
insert_final_newline = false
[*.md]
trim_trailing_whitespace = false
[test/fixtures/**/*.expected.*]
trim_trailing_whitespace = false
insert_final_newline = false
================================================
FILE: .github/ISSUE_TEMPLATE/bug_report.md
================================================
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
- [ ] Check if updating to the latest `preact-iso` version resolves the issue
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Please provide a link to a StackBlitz/CodeSandbox/Codepen project or a GitHub repository that demonstrates the issue. You can use the following template on StackBlitz to get started: https://stackblitz.com/edit/create-preact-starter-routing
Issues without reproductions will likely be closed, they're essential for ensuring we're all on the same page.
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. See error
**Expected behavior**
What should have happened when following the steps above?
================================================
FILE: .github/ISSUE_TEMPLATE/feature_request.md
================================================
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: feature request
assignees: ''
---
**Describe the feature you'd love to see**
A clear and concise description of what you'd love to see added to `preact-iso`.
**Additional context (optional)**
Add any other context or screenshots about the feature request here.
================================================
FILE: .github/workflows/ci.yml
================================================
name: CI
on:
push:
branches:
- main
pull_request:
branches:
- '**'
jobs:
build_test:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Tests
run: npm run test
================================================
FILE: .gitignore
================================================
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/*
!.yarn/releases
!.yarn/plugins
.pnp.*
# testing
/coverage
# production
/dist
/build
/.yalc
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
================================================
FILE: CODE_OF_CONDUCT.md
================================================
# Contributor Covenant Code of Conduct
## Our Pledge
In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
## Our Standards
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities
Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
## Scope
This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
## Enforcement
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at hello@preactjs.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
## Attribution
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2020 The Preact Authors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: README.md
================================================
# preact-iso
[](https://chat.preactjs.com/)
Isomorphic async tools for Preact.
- Lazy-load components using `lazy()` and `<ErrorBoundary>`, which also enables progressive hydration.
- Generate static HTML for your app using `prerender()`, waiting for `lazy()` components and data dependencies.
- Implement async-aware client and server-side routing using `<Router>`, including seamless async transitions.
---
- [Routing](#routing)
- [Prerendering](#prerendering)
- [Nested Routing](#nested-routing)
- [Non-JS Servers](#non-js-servers)
- [API Docs](#api-docs)
- [\<LocationProvider\>](#locationprovider)
- [\<Router\>](#router)
- [\<Route\>](#route)
- [Path Segment Matching](#path-segment-matching)
- [useLocation()](#uselocation)
- [useRoute()](#useroute)
- [lazy()](#lazy)
- [\<ErrorBoundary\>](#errorboundary)
- [hydrate()](#hydrate)
- [prerender()](#prerender)
- [locationStub()](#locationstub)
- [Navigation API Entry Docs](#navigation-api-entry-docs)
- [Differences in usage](#differences-in-usage)
---
## Routing
`preact-iso` offers a simple router for Preact with conventional and hooks-based APIs. The `<Router>` component is async-aware: when transitioning from one route to another, if the incoming route suspends (throws a Promise), the outgoing route is preserved until the new one becomes ready.
```js
import { lazy, LocationProvider, ErrorBoundary, Router, Route } from 'preact-iso';
// Synchronous
import Home from './routes/home.js';
// Asynchronous (throws a promise)
const Profiles = lazy(() => import('./routes/profiles.js'));
const Profile = lazy(() => import('./routes/profile.js'));
const NotFound = lazy(() => import('./routes/_404.js'));
const App = () => (
<LocationProvider>
<ErrorBoundary>
<Router>
<Home path="/" />
{/* Alternative dedicated route component for better TS support */}
<Route path="/profiles" component={Profiles} />
<Route path="/profile/:id" component={Profile} />
{/* `default` prop indicates a fallback route. Useful for 404 pages */}
<NotFound default />
</Router>
</ErrorBoundary>
</LocationProvider>
);
```
**Progressive Hydration:** When the app is hydrated on the client, the route (`Home` or `Profile` in this case) suspends. This causes hydration for that part of the page to be deferred until the route's `import()` is resolved, at which point that part of the page automatically finishes hydrating.
**Seamless Routing:** When switching between routes on the client, the Router is aware of asynchronous dependencies in routes. Instead of clearing the current route and showing a loading spinner while waiting for the next route, the router preserves the current route in-place until the incoming route has finished loading, then they are swapped.
## Prerendering
`prerender()` renders a Virtual DOM tree to an HTML string using [`preact-render-to-string`](https://github.com/preactjs/preact-render-to-string). The Promise returned from `prerender()` resolves to an Object with `html` and `links[]` properties. The `html` property contains your pre-rendered static HTML markup, and `links` is an Array of any non-external URL strings found in links on the generated page.
Primarily meant for use with prerendering via [`@preact/preset-vite`](https://github.com/preactjs/preset-vite#prerendering-configuration) or other prerendering systems that share the API. If you're server-side rendering your app via any other method, you can use `preact-render-to-string` (specifically `renderToStringAsync()`) directly.
```js
import { LocationProvider, ErrorBoundary, Router, lazy, prerender as ssr } from 'preact-iso';
// Asynchronous (throws a promise)
const Foo = lazy(() => import('./foo.js'));
const App = () => (
<LocationProvider>
<ErrorBoundary>
<Router>
<Foo path="/" />
</Router>
</ErrorBoundary>
</LocationProvider>
);
hydrate(<App />);
export async function prerender(data) {
return await ssr(<App />);
}
```
## Nested Routing
Some applications would benefit from having routers of multiple levels, allowing to break down the routing logic into smaller components. This is especially useful for larger applications, and we solve this by allowing for multiple nested `<Router>` components.
Partially matched routes end with a wildcard (`/*`) and only the remaining value will be passed to descendant routers for further matching. This allows you to create a parent route that matches a base path, and then have child routes that match specific sub-paths.
```js
import { lazy, LocationProvider, ErrorBoundary, Router, Route } from 'preact-iso';
import AllMovies from './routes/movies/all.js';
const NotFound = lazy(() => import('./routes/_404.js'));
const App = () => (
<LocationProvider>
<ErrorBoundary>
<Router>
<Router path="/movies" component={AllMovies} />
<Route path="/movies/*" component={Movies} />
<NotFound default />
</Router>
</ErrorBoundary>
</LocationProvider>
);
const TrendingMovies = lazy(() => import('./routes/movies/trending.js'));
const SearchMovies = lazy(() => import('./routes/movies/search.js'));
const MovieDetails = lazy(() => import('./routes/movies/details.js'));
const Movies = () => (
<ErrorBoundary>
<Router>
<Route path="/trending" component={TrendingMovies} />
<Route path="/search" component={SearchMovies} />
<Route path="/:id" component={MovieDetails} />
</Router>
</ErrorBoundary>
);
```
The `<Movies>` component will be used for the following routes:
- `/movies/trending`
- `/movies/search`
- `/movies/Inception`
- `/movies/...`
It will not be used for any of the following:
- `/movies`
- `/movies/`
## Non-JS Servers
For those using non-JS servers (e.g., PHP, Python, Ruby, etc.) to serve your Preact app, you may want to use our ["polyglot-utils"](./polyglot-utils), a collection of our route matching logic ported to various other languages. Combined with a route manifest, this will allow your server to better understand which assets will be needed at runtime for a given URL, allowing you to say insert preload tags for those assets in the HTML head prior to serving the page.
---
## API Docs
### `LocationProvider`
A context provider that provides the current location to its children. This is required for the router to function.
Props:
- `scope?: string | RegExp` - Sets a scope for the paths that the router will handle (intercept). If a path does not match the scope, either by starting with the provided string or matching the RegExp, the router will ignore it and default browser navigation will apply.
Typically, you would wrap your entire app in this provider:
```js
import { LocationProvider } from 'preact-iso';
const App = () => (
<LocationProvider scope="/app">
{/* Your app here */}
</LocationProvider>
);
```
#### Restore default browser navigation for one link
The location provider intercepts interactions upon anchor tags (`<a />`) to facilitate single-page app navigation. Some interactions, however, are ignored to avoid hindering users as well as giving devs ways to opt-out and resume standard browser behavior. The rules for ignoring are as such:
1. You hold a modifier key (ctrl, meta, alt, shift) or click a mouse button other than the main one.
2. Cross-origin links, e.g., you're on `https://example.org` but the link points to `https://example.com`.
3. The link points to a fragment identifier on the current page, e.g., to `#section`.
4. The link's `target` is set to anything but `_self` (such as `_blank` or `_top`) -- this is a convenient way to opt-out.
5. The link points outside the `scope` of your `LocationProvider`.
6. The link has a `download` attribute.
### `Router`
Props:
- `onRouteChange?: (url: string) => void` - Callback to be called when a route changes.
- `onLoadStart?: (url: string) => void` - Callback to be called when a route starts loading (i.e., if it suspends). This will not be called before navigations to sync routes or subsequent navigations to async routes.
- `onLoadEnd?: (url: string) => void` - Callback to be called after a route finishes loading (i.e., if it suspends). This will not be called after navigations to sync routes or subsequent navigations to async routes.
```js
import { LocationProvider, Router } from 'preact-iso';
const App = () => (
<LocationProvider>
<Router
onRouteChange={(url) => console.log('Route changed to', url)}
onLoadStart={(url) => console.log('Starting to load', url)}
onLoadEnd={(url) => console.log('Finished loading', url)}
>
<Home path="/" />
<Profiles path="/profiles" />
<Profile path="/profile/:id" />
</Router>
</LocationProvider>
);
```
### `Route`
There are two ways to define routes using `preact-iso`:
1. Append router params to the route components directly: `<Home path="/" />`
2. Use the `Route` component instead: `<Route path="/" component={Home} />`
Appending arbitrary props to components not unreasonable in JavaScript, as JS is a dynamic language that's perfectly happy to support dynamic & arbitrary interfaces. However, TypeScript, which many of us use even when writing JS (via TS's language server), is not exactly a fan of this sort of interface design.
TS does not (yet) allow for overriding a child's props from the parent component so we cannot, for instance, define `<Home>` as taking no props _unless_ it's a child of a `<Router>`, in which case it can have a `path` prop. This leaves us with a bit of a dilemma: either we define all of our routes as taking `path` props so we don't see TS errors when writing `<Home path="/" />` or we create wrapper components to handle the route definitions.
While `<Home path="/" />` is completely equivalent to `<Route path="/" component={Home} />`, TS users may find the latter preferable.
```js
import { LocationProvider, Router, Route } from 'preact-iso';
const App = () => (
<LocationProvider>
<Router>
{/* Both of these are equivalent */}
<Home path="/" />
<Route path="/" component={Home} />
<Profiles path="/profiles" />
<Profile path="/profile/:id" />
<NotFound default />
</Router>
</LocationProvider>
);
```
Props for any route component:
- `path: string` - The path to match (read on)
- `default?: boolean` - If set, this route is a fallback/default route to be used when nothing else matches
Specific to the `Route` component:
- `component: AnyComponent` - The component to render when the route matches
#### Path Segment Matching
Paths are matched using a simple string matching algorithm. The following features may be used:
- `:param` - Matches any URL segment, binding the value to the label (can later extract this value from `useRoute()`)
- `/profile/:id` will match `/profile/123` and `/profile/abc`
- `/profile/:id?` will match `/profile` and `/profile/123`
- `/profile/:id*` will match `/profile`, `/profile/123`, and `/profile/123/abc`
- `/profile/:id+` will match `/profile/123`, `/profile/123/abc`
- `*` - Matches one or more URL segments
- `/profile/*` will match `/profile/123`, `/profile/123/abc`, etc.
These can then be composed to create more complex routes:
- `/profile/:id/*` will match `/profile/123/abc`, `/profile/123/abc/def`, etc.
The difference between `/:id*` and `/:id/*` is that in the former, the `id` param will include the entire path after it, while in the latter, the `id` is just the single path segment.
- `/profile/:id*`, with `/profile/123/abc`
- `id` is `123/abc`
- `/profile/:id/*`, with `/profile/123/abc`
- `id` is `123`
You can narrow prop types for your routes using `RoutePropsForPath<path>`:
```ts
import type { RoutePropsForPath } from 'preact-iso'
function User(props: RoutePropsForPath<'/user/:id'>) {
props.user.id2 // type error
props.user.id // no type error
}
```
### `useLocation`
A hook to work with the `LocationProvider` to access location context.
Returns an object with the following properties:
- `url: string` - The current path & search params
- `path: string` - The current path
- `query: Record<string, string>` - The current query string parameters (`/profile?name=John` -> `{ name: 'John' }`)
- `route: (url: string, replace?: boolean) => void` - A function to programmatically navigate to a new route. The `replace` param can optionally be used to overwrite history, navigating them away without keeping the current location in the history stack.
- `back: () => void` - A function to programmatically navigate back one entry in the browser history stack.
- `forward: () => void` - A function to programmatically navigate forward one entry in the browser history stack.
### `useRoute`
A hook to access current route information. Unlike `useLocation`, this hook only works within `<Router>` components.
Returns an object with the following properties:
- `path: string` - The current path
- `query: Record<string, string>` - The current query string parameters (`/profile?name=John` -> `{ name: 'John' }`)
- `params: Record<string, string>` - The current route parameters (`/profile/:id` -> `{ id: '123' }`)
### `lazy`
Make a lazily-loaded version of a Component.
`lazy()` takes an async function that resolves to a Component, and returns a wrapper version of that Component. The wrapper component can be rendered right away, even though the component is only loaded the first time it is rendered.
```js
import { lazy, LocationProvider, Router } from 'preact-iso';
// Synchronous, not code-splitted:
import Home from './routes/home.js';
// Asynchronous, code-splitted:
const Profiles = lazy(() => import('./routes/profiles.js').then(m => m.Profiles)); // Expects a named export called `Profiles`
const Profile = lazy(() => import('./routes/profile.js')); // Expects a default export
const App = () => (
<LocationProvider>
<Router>
<Home path="/" />
<Profiles path="/profiles" />
<Profile path="/profile/:id" />
</Router>
</LocationProvider>
);
```
The result of `lazy()` also exposes a `preload()` method that can be used to load the component before it's needed for rendering. Entirely optional, but can be useful on focus, mouse over, etc. to start loading the component a bit earlier than it otherwise would be.
```js
const Profile = lazy(() => import('./routes/profile.js'));
function Home() {
return (
<a href="/profile/rschristian" onMouseOver={() => Profile.preload()}>
Profile Page -- Hover over me to preload the module!
</a>
);
}
```
### `ErrorBoundary`
A simple component to catch errors in the component tree below it.
Props:
- `onError?: (error: Error) => void` - A callback to be called when an error is caught
```js
import { LocationProvider, ErrorBoundary, Router } from 'preact-iso';
const App = () => (
<LocationProvider>
<ErrorBoundary onError={(e) => console.log(e)}>
<Router>
<Home path="/" />
<Profiles path="/profiles" />
<Profile path="/profile/:id" />
</Router>
</ErrorBoundary>
</LocationProvider>
);
```
### `hydrate`
A thin wrapper around Preact's `hydrate` export, it switches between hydrating and rendering the provided element, depending on whether the current page has been prerendered. Additionally, it checks to ensure it's running in a browser context before attempting any rendering, making it a no-op during SSR.
Pairs with the `prerender()` function.
Params:
- `jsx: ComponentChild` - The JSX element or component to render
- `parent?: Element | Document | ShadowRoot | DocumentFragment` - The parent element to render into. Defaults to `document.body` if not provided.
```js
import { hydrate } from 'preact-iso';
const App = () => (
<div class="app">
<h1>Hello World</h1>
</div>
);
hydrate(<App />);
```
However, it is just a simple utility method. By no means is it essential to use, you can always use Preact's `hydrate` export directly.
### `prerender`
Renders a Virtual DOM tree to an HTML string using `preact-render-to-string`. The Promise returned from `prerender()` resolves to an Object with `html` and `links[]` properties. The `html` property contains your pre-rendered static HTML markup, and `links` is an Array of any non-external (either no `target` or something other than `target="_self"`) URL strings found in links on the generated page.
Pairs primarily with [`@preact/preset-vite`](https://github.com/preactjs/preset-vite#prerendering-configuration)'s prerendering.
Params:
- `jsx: ComponentChild` - The JSX element or component to render
```js
import { LocationProvider, ErrorBoundary, Router, lazy, prerender } from 'preact-iso';
// Asynchronous (throws a promise)
const Foo = lazy(() => import('./foo.js'));
const Bar = lazy(() => import('./bar.js'));
const App = () => (
<LocationProvider>
<ErrorBoundary>
<Router>
<Foo path="/" />
<Bar path="/bar" />
</Router>
</ErrorBoundary>
</LocationProvider>
);
const { html, links } = await prerender(<App />);
```
### `locationStub`
A utility function to imitate the `location` object in a non-browser environment. Our router relies upon this to function, so if you are using `preact-iso` outside of a browser context and are not prerendering via `@preact/preset-vite` (which does this for you), you can use this utility to set a stubbed `location` object.
```js
import { locationStub } from 'preact-iso/prerender';
locationStub('/foo/bar?baz=qux#quux');
console.log(location.pathname); // "/foo/bar"
```
## Navigation API Entry Docs
The Navigation API is a new web standard that provides an updated method of handling "navigation" in web applications, supporting SPA-style routing as a first-class citizen. The older History API can be wrangled to support this and has been the standard for many years, but the Navigation API provides a much more robust set of tools that are really, really attractive for routers like `preact-iso` to take advantage of.
Whilst the API [sees fairly wide support](https://caniuse.com/wf-navigation), it is still newly available and thus may not be viable for some targets. As such, we've provided a new entry point that will allow you to take advantage of this API if you wish, but the default remains targetting the History API. The Navigation API entry point is available at `preact-iso/router/navigation-api`.
### Differences in usage
The differences lie entirely within the [`useLocation()`](#uselocation) hook: instead of returning a `route()` function, you use the global `navigation` object to perform all navigations.
The [`navigation` object](https://developer.mozilla.org/en-US/docs/Web/API/Navigation) contains many of the useful utilities that go along with a router, like `.forward()`, `.back()`, `.canGoForward()`, `.canGoBack()`, `.entries()`, etc. It actually offers far more utilities than the base router did, and does so with less library code overall, so if you have access to it, it's a really nice upgrade.
## License
[MIT](./LICENSE)
================================================
FILE: jsconfig.json
================================================
{
"compilerOptions": {
"target": "ESNext",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"noEmit": true,
"allowJs": true,
"checkJs": true,
"skipLibCheck": false,
"jsx": "react",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
}
}
================================================
FILE: package.json
================================================
{
"name": "preact-iso",
"version": "2.12.0",
"type": "module",
"main": "src/index.js",
"module": "src/index.js",
"types": "src/index.d.ts",
"exports": {
".": "./src/index.js",
"./router": "./src/router.js",
"./router/navigation-api": "./src/router-navigation-api.js",
"./lazy": "./src/lazy.js",
"./prerender": "./src/prerender.js",
"./hydrate": "./src/hydrate.js"
},
"sideEffects": false,
"license": "MIT",
"description": "Isomorphic utilities for Preact",
"author": "The Preact Authors (https://preactjs.com)",
"repository": {
"type": "git",
"url": "git+https://github.com/preactjs/preact-iso.git"
},
"files": [
"src",
"!src/internal.d.ts",
"LICENSE",
"package.json",
"README.md"
],
"scripts": {
"test": "npm run test:node && npm run test:browser",
"test:browser": "wtr test/*.test.js",
"test:node": "uvu test/node"
},
"peerDependencies": {
"preact": ">=10 || >= 11.0.0-0",
"preact-render-to-string": ">=6.4.0"
},
"devDependencies": {
"@types/mocha": "^10.0.7",
"@types/sinon-chai": "^3.2.12",
"@web/dev-server-esbuild": "^1.0.2",
"@web/test-runner": "^0.18.3",
"chai": "^5.1.1",
"htm": "^3.1.1",
"kleur": "^4.1.5",
"navigation-api-types": "^0.6.1",
"preact": "^10.26.5",
"preact-render-to-string": "^6.6.1",
"sinon": "^18.0.0",
"sinon-chai": "^4.0.0",
"uvu": "^0.5.6"
},
"overrides": {
"@web/dev-server-core": "0.7.1"
}
}
================================================
FILE: polyglot-utils/.gitignore
================================================
# Language-specific files to ignore
__pycache__/
*.pyc
.venv/
.ruby-version
================================================
FILE: polyglot-utils/README.md
================================================
# Preact ISO URL Pattern Matching - Polyglot Utils
Multi-language implementations of URL pattern matching utilities for building bespoke server setups that need to preload JS/CSS resources or handle early 404 responses.
## Use Case
This utility is designed for server languages that **cannot do SSR/prerendering** but still want to provide better experiences. It enables servers to:
- **Add preload head tags** for JS,CSS before serving HTML
- **Return early 404 pages** for unmatched routes
- **Generate dynamic titles** based on route parameters
## How can I implement preloading of JS, CSS?
Typical implementation flow:
1. **Build-time Setup:**
- Write your routes as an array in a JS file
- Create a build script that exports route patterns and entry files to a `.json` file
- Configure your frontend build tool to output a `manifest` file mapping entry files to final fingerprinted/hashed output JS/CSS files and dependencies
2. **Server-time Processing:**
- Load the JSON route file when a request comes in
- Match the requested URL against each route pattern until you find a match
- Once matched, you have the source entry `.jsx` file
- Load the build manifest file to find which JS chunk contains that code and its dependency files
- Generate `<link rel="preload">` tags for each dependency (JS, CSS, images, icons)
- Inject those head tags into the HTML before serving
3. **Result:**
- Browsers start downloading critical resources immediately
- Faster page loads without full SSR complexity
- Early 404s for invalid routes
### Example - preloading of JS, CSS
Here's how you might integrate this into a server setup. Let's say you have a client side `routes.js` as follows:
```js
import { lazy } from 'preact-iso';
export const routes = [
{
"path": "/users/:userId/posts",
"component": lazy(() => import("pages/UserPosts.jsx")),
"title": "Posts by :userId"
},
{
"path": "/products/:category/:id",
"component": lazy(() => import("pages/Product.jsx")),
"title": "Product :id"
}
];
```
1. **Generate Routes JSON (routes.json)**
You can use the following standalone node.js script to create `routes.json` during build (you could convert it into a plugin for your frontend build tool):
```js
const routeDir = path.resolve(__dirname, 'client/src/routes');
let routesFile = fs.readFileSync(path.resolve(routeDir, 'routes.js'), 'utf-8');
routesFile = routesFile.replace(/lazy\s*\(\s*\(\s*\)\s*=>\s*import\s*\(\s*(.+)\s*\)\s*\)\s*(,?)/g, '$1$2');
const fileName = path.resolve(__dirname, 'routes-temp.js')
fs.writeFileSync(fileName, routesFile, 'utf-8');
const routes = (await import(fileName)).default;
fs.unlinkSync(fileName);
const routeInfo = routes.map((route) => ({
path: route.path,
title: typeof route.title === 'string' ? route.title : null,
Component: path.relative(__dirname, path.resolve(routeDir, `${route.Component}.jsx`)),
default: route.default,
}));
// console.log(routeInfo);
fs.writeFileSync(
path.resolve(__dirname, 'dist/routes.json'),
JSON.stringify(routeInfo, null, 2),
'utf-8'
);
```
The script produces the following `routes.json` file:
```json
[
{
"path": "/users/:userId/posts",
"component": "pages/UserPosts.jsx",
"title": "Posts by :userId"
},
{
"path": "/products/:category/:id",
"component": "pages/Product.jsx",
"title": "Product :id"
}
]
```
2. **Build Manifest (manifest.json)**
This is the file your client build tool generates. Check your build tool's documentation for exact format. Below is an example with few important fields from a Vite manifest file:
```json
{
"pages/UserPosts.jsx": {
"file": "assets/UserPosts-abc123.js",
"css": ["assets/UserPosts-def456.css"],
"imports": ["chunks/shared-ghi789.js"]
}
}
```
3. **Server Implementation**
```python
# Python example
import json
routes = json.load(open('../dist/routes.json'))
manifest = json.load(open('../dist/.vite/manifest.json'))
def handle_request(url_path):
for route in routes:
matches = preact_iso_url_pattern_match(url_path, route['path'])
if matches:
# Generate preload tags
component = route['component']
entry_info = manifest[component]
preload_tags = []
for js_file in [entry_info['file']] + entry_info.get('imports', []):
preload_tags.append(f'<link rel="modulepreload" crossorigin href="{js_file}">')
for css_file in entry_info.get('css', []):
preload_tags.append(f'<link rel="stylesheet" crossorigin href="{css_file}">')
# Generate dynamic title
title = route['title']
for param, value in matches['params'].items():
title = title.replace(f':{param}', value)
return {
'preload_tags': preload_tags,
'title': title,
'params': matches['params']
}
# No match found - return early 404
return None
```
This approach gives you the performance benefits of resource preloading without the complexity of full server-side rendering.
## Available Languages
Go, PHP, Python and Ruby.
Find the corresponding language's sub-directory. Each language has a README that contains usage examples and API reference.
## Running Tests
```bash
# Run all tests across all languages
./run_tests.sh
# Or run individual language tests
cd go && go test -v
cd python && python3 test_preact_iso_url_pattern.py
cd ruby && ruby test_preact_iso_url_pattern.rb
cd php && php test_preact_iso_url_pattern.php
```
================================================
FILE: polyglot-utils/go/README.md
================================================
# Go Implementation
URL pattern matching utility for Go servers.
## Setup
Code tested on Go 1.24.x.
```sh
# If using in a project, initialize go module
go mod init myproject
# No third party dependencies needed. Just run the tests or use the function directly
```
## Running Tests
```sh
go test -v
```
## Usage
```go
package main
import "fmt"
func main() {
matches := preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts", nil)
if matches != nil {
fmt.Printf("User ID: %s\n", matches.Params["userId"]) // Output: test@example.com
}
}
```
## Function Signature
```go
func preactIsoUrlPatternMatch(url, route string, matches *Matches) *Matches
```
### Parameters
- `url` (string): The URL path to match
- `route` (string): The route pattern with parameters
- `matches` (*Matches): Optional pre-existing matches to extend
### Return Value
Returns a `*Matches` struct on success, or `nil` if no match:
```go
type Matches struct {
Params map[string]string
Rest string
}
```
## Route Patterns
| Pattern | Description | Example |
|---------|-------------|---------|
| `/users/:id` | Named parameter | `{id: "123"}` |
| `/users/:id?` | Optional parameter | `{id: ""}` |
| `/files/:path+` | Required rest parameter | `{path: "docs/readme.txt"}` |
| `/static/:path*` | Optional rest parameter | `{path: "css/main.css"}` |
| `/static/*` | Anonymous wildcard | `{Rest: "/images/logo.png"}` |
================================================
FILE: polyglot-utils/go/go.mod
================================================
module myproject
go 1.13
================================================
FILE: polyglot-utils/go/preact_iso_url_pattern.go
================================================
// Run program: go run preact-iso-url-pattern.go
package main
import (
// "fmt"
"net/url"
"regexp"
"strings"
)
type Matches struct {
Params map[string]string `json:"params"`
Rest string `json:"rest,omitempty"`
}
func preactIsoUrlPatternMatch(urlStr, route string, matches *Matches) *Matches {
if matches == nil {
matches = &Matches{
Params: make(map[string]string),
}
}
urlParts := filterEmpty(strings.Split(urlStr, "/"))
routeParts := filterEmpty(strings.Split(route, "/"))
for i := 0; i < max(len(urlParts), len(routeParts)); i++ {
var m, param, flag string
if i < len(routeParts) {
re := regexp.MustCompile(`^(:?)(.*?)([+*?]?)$`)
matches := re.FindStringSubmatch(routeParts[i])
if len(matches) > 3 {
m, param, flag = matches[1], matches[2], matches[3]
}
}
var val string
if i < len(urlParts) {
val = urlParts[i]
}
// segment match:
if m == "" && param != "" && param == val {
continue
}
// /foo/* match
if m == "" && val != "" && flag == "*" {
matches.Rest = "/" + strings.Join(urlParts[i:], "/")
break
}
// segment mismatch / missing required field:
if m == "" || (val == "" && flag != "?" && flag != "*") {
return nil
}
rest := flag == "+" || flag == "*"
// rest (+/*) match:
if rest {
decodedParts := make([]string, len(urlParts[i:]))
for j, part := range urlParts[i:] {
decoded, err := url.QueryUnescape(part)
if err != nil {
decoded = part // fallback to original if decode fails
}
decodedParts[j] = decoded
}
val = strings.Join(decodedParts, "/")
} else if val != "" {
// normal/optional field: decode val (like JavaScript does)
decoded, err := url.QueryUnescape(val)
if err != nil {
decoded = urlParts[i]
}
val = decoded
}
matches.Params[param] = val
if rest {
break
}
}
return matches
}
func filterEmpty(s []string) []string {
var result []string
for _, str := range s {
if str != "" {
result = append(result, str)
}
}
return result
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
// Example usage:
// func main() {
// params := &Matches{Params: make(map[string]string)}
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar%20baz", "/foo/:param", params))
//
// params := &Matches{Params: make(map[string]string)}
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar/baz", "/foo/*"))
//
// params := &Matches{Params: make(map[string]string)}
// fmt.Println(preactIsoUrlPatternMatch("/foo", "/foo/:param?"))
//
// params := &Matches{Params: make(map[string]string)}
// fmt.Println(preactIsoUrlPatternMatch("/foo/bar", "/bar/:param"))
//
// params := &Matches{Params: make(map[string]string)}
// fmt.Println(preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts"))
// }
================================================
FILE: polyglot-utils/go/preact_iso_url_pattern_test.go
================================================
package main
import (
"reflect"
"strings"
"testing"
)
// Note 1: This is different from JS implementation. Here it is empty string and not nil
// This was intentionally implemented this way, but we can change if it's a problem
func TestPreactIsoUrlPatternMatch(t *testing.T) {
tests := []struct {
name string
url string
route string
matches *Matches
expected *Matches
}{
// Base route tests
{
name: "Base route - exact match",
url: "/",
route: "/",
matches: nil,
expected: &Matches{Params: map[string]string{}},
},
{
name: "Base route - no match",
url: "/user/1",
route: "/",
matches: nil,
expected: nil,
},
// Param route tests
{
name: "Param route - match",
url: "/user/2",
route: "/user/:id",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "2"},
},
},
{
name: "Param route - no match",
url: "/",
route: "/user/:id",
matches: nil,
expected: nil,
},
// Rest segment tests
{
name: "Rest segment - match",
url: "/user/foo",
route: "/user/*",
matches: nil,
expected: &Matches{
Params: map[string]string{},
Rest: "/foo",
},
},
{
name: "Rest segment - match multiple segments",
url: "/user/foo/bar/baz",
route: "/user/*",
matches: nil,
expected: &Matches{
Params: map[string]string{},
Rest: "/foo/bar/baz",
},
},
{
name: "Rest segment - no match",
url: "/user",
route: "/user/*",
matches: nil,
expected: nil,
},
// Param route with rest segment
{
name: "Param with rest - single segment",
url: "/user/2/foo",
route: "/user/:id/*",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "2"},
Rest: "/foo",
},
},
{
name: "Param with rest - multiple segments",
url: "/user/2/foo/bar/bob",
route: "/user/:id/*",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "2"},
Rest: "/foo/bar/bob",
},
},
{
name: "Param with rest - no match",
url: "/",
route: "/user/:id/*",
matches: nil,
expected: nil,
},
// Optional param tests
{
name: "Optional param - empty",
url: "/user",
route: "/user/:id?",
matches: nil,
expected: &Matches{
// Check "Note 1" at the top of the file
Params: map[string]string{"id": ""},
},
},
{
name: "Optional param - no match base",
url: "/",
route: "/user/:id?",
matches: nil,
expected: nil,
},
// Optional rest param tests (/:x*)
{
name: "Optional rest param - empty",
url: "/user",
route: "/user/:id*",
matches: nil,
expected: &Matches{
// Check "Note 1" at the top of the file
Params: map[string]string{"id": ""},
},
},
{
name: "Optional rest param - with segments",
url: "/user/foo/bar",
route: "/user/:id*",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "foo/bar"},
},
},
{
name: "Optional param - no match base",
url: "/",
route: "/user/:id*",
matches: nil,
expected: nil,
},
// Required rest param tests (/:x+)
{
name: "Required rest param - single segment",
url: "/user/foo",
route: "/user/:id+",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "foo"},
},
},
{
name: "Required rest param - multiple segments",
url: "/user/foo/bar",
route: "/user/:id+",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "foo/bar"},
},
},
{
name: "Required rest param - empty (should fail)",
url: "/user",
route: "/user/:id+",
matches: nil,
expected: nil,
},
{
name: "Required rest param - root mismatch",
url: "/",
route: "/user/:id+",
matches: nil,
expected: nil,
},
// Leading/trailing slashes
{
name: "Leading/trailing slashes",
url: "/about-late/_SEGMENT1_/_SEGMENT2_/",
route: "/about-late/:seg1/:seg2/",
matches: nil,
expected: &Matches{
Params: map[string]string{
"seg1": "_SEGMENT1_",
"seg2": "_SEGMENT2_",
},
},
},
// Additional tests that are not in test/node/router-match.test.js
// URL encoding tests
{
name: "URL encoded param",
url: "/foo/bar%20baz",
route: "/foo/:param",
matches: nil,
expected: &Matches{
Params: map[string]string{"param": "bar baz"},
},
},
{
name: "URL encoded email in param",
url: "/users/test%40example.com/posts",
route: "/users/:userId/posts",
matches: nil,
expected: &Matches{
Params: map[string]string{"userId": "test@example.com"},
},
},
// Complex rest segment with encoding
{
name: "Rest segment with encoded parts",
url: "/api/path/with%20spaces/and%2Fslashes",
route: "/api/:path+",
matches: nil,
expected: &Matches{
Params: map[string]string{"path": "path/with spaces/and/slashes"},
},
},
// Edge cases
{
name: "Empty route",
url: "/foo",
route: "",
matches: nil,
expected: nil,
},
{
name: "Empty url with param",
url: "",
route: "/:param",
matches: nil,
expected: nil,
},
{
name: "Mixed required and optional params",
url: "/foo/bar",
route: "/:required/:optional?",
matches: nil,
expected: &Matches{
Params: map[string]string{"required": "foo", "optional": "bar"},
},
},
{
name: "Mixed required and optional params - missing optional",
url: "/foo",
route: "/:required/:optional?",
matches: nil,
expected: &Matches{
Params: map[string]string{"required": "foo", "optional": ""},
},
},
// Test with pre-existing matches
{
name: "Pre-existing matches object",
url: "/foo/bar",
route: "/:first/:second",
matches: &Matches{Params: map[string]string{"existing": "value"}},
expected: &Matches{
Params: map[string]string{
"existing": "value",
"first": "foo",
"second": "bar",
},
},
},
// Complex nested paths
{
name: "Complex nested path with multiple params",
url: "/api/v1/users/123/posts/456/comments",
route: "/api/:version/users/:userId/posts/:postId/comments",
matches: nil,
expected: &Matches{
Params: map[string]string{
"version": "v1",
"userId": "123",
"postId": "456",
},
},
},
// Test case where route is longer than URL
{
name: "Route longer than URL - required param missing",
url: "/api",
route: "/api/:version/:resource",
matches: nil,
expected: nil,
},
{
name: "Route longer than URL - optional param",
url: "/api",
route: "/api/:version?",
matches: nil,
expected: &Matches{
Params: map[string]string{"version": ""},
},
},
{
name: "Multiple slashes in URL should be normalized",
url: "//user//123//",
route: "/user/:id",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "123"},
},
},
{
name: "Route with multiple slashes",
url: "/user/123",
route: "//user//:id//",
matches: nil,
expected: &Matches{
Params: map[string]string{"id": "123"},
},
},
{
name: "Complex URL encoding in rest params",
url: "/files/folder%2Fsubfolder/file%20name.txt",
route: "/files/:path+",
matches: nil,
expected: &Matches{
Params: map[string]string{"path": "folder/subfolder/file name.txt"},
},
},
{
name: "Special characters encoded in URL",
url: "/search/query%3F%2B%23%26test",
route: "/search/:query",
matches: nil,
expected: &Matches{
Params: map[string]string{"query": "query?+#&test"},
},
},
{
name: "Unicode characters encoded",
url: "/user/Jos%C3%A9",
route: "/user/:name",
matches: nil,
expected: &Matches{
Params: map[string]string{"name": "José"},
},
},
{
name: "Empty segments in middle of URL",
url: "/api//v1//users",
route: "/api/v1/users",
matches: nil,
expected: &Matches{
Params: map[string]string{},
},
},
{
name: "Route with only wildcards",
url: "/anything/goes/here",
route: "*",
matches: nil,
expected: &Matches{
Params: map[string]string{},
Rest: "/anything/goes/here",
},
},
}
// URL decoding error handling tests
urlDecodingTests := []struct {
name string
url string
route string
matches *Matches
}{
{
name: "Malformed percent encoding in simple param - should not crash",
url: "/user/test%",
route: "/user/:id",
matches: nil,
},
{
name: "Malformed percent encoding in rest param - should not crash",
url: "/files/test%/file",
route: "/files/:path+",
matches: nil,
},
{
name: "Invalid unicode sequence - should not crash",
url: "/user/test%C3",
route: "/user/:id",
matches: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := preactIsoUrlPatternMatch(tt.url, tt.route, tt.matches)
if tt.expected == nil {
if result != nil {
t.Errorf("Expected nil, got %+v", result)
}
return
}
if result == nil {
t.Errorf("Expected %+v, got nil", tt.expected)
return
}
// Detailed debugging for failing tests
if !reflect.DeepEqual(result.Params, tt.expected.Params) {
t.Errorf("Params mismatch for url=%q route=%q", tt.url, tt.route)
t.Errorf(" Expected: %+v", tt.expected.Params)
t.Errorf(" Got: %+v", result.Params)
// Additional debug info for rest param cases
if strings.Contains(tt.route, "+") || strings.Contains(tt.route, "*") {
urlParts := filterEmpty(strings.Split(tt.url, "/"))
routeParts := filterEmpty(strings.Split(tt.route, "/"))
t.Errorf(" Debug: urlParts=%v, routeParts=%v", urlParts, routeParts)
}
}
// Check rest
if result.Rest != tt.expected.Rest {
t.Errorf("Rest mismatch. Expected %q, got %q", tt.expected.Rest, result.Rest)
}
})
}
// Test URL decoding error handling - these should not crash
for _, tt := range urlDecodingTests {
t.Run(tt.name, func(t *testing.T) {
// The main requirement is that this doesn't crash
// We don't care about the exact return value as long as it doesn't panic
result := preactIsoUrlPatternMatch(tt.url, tt.route, tt.matches)
// Should either work or return nil, but not crash
if result != nil {
// If it returns a result, just verify it has params
if result.Params == nil {
t.Errorf("Result should have non-nil Params map")
}
}
})
}
}
// Debug helper to trace execution
func TestDebugSpecificCase(t *testing.T) {
t.Run("Debug rest param multiple segments", func(t *testing.T) {
url := "/user/foo/bar"
route := "/user/:id*"
t.Logf("Testing: url=%q route=%q", url, route)
// Manual trace of what should happen:
urlParts := filterEmpty(strings.Split(url, "/"))
routeParts := filterEmpty(strings.Split(route, "/"))
t.Logf("urlParts: %v", urlParts)
t.Logf("routeParts: %v", routeParts)
t.Logf("Expected at i=1: should take urlParts[1:] = %v", urlParts[1:])
t.Logf("Expected result: %q", strings.Join(urlParts[1:], "/"))
result := preactIsoUrlPatternMatch(url, route, nil)
if result != nil {
t.Logf("Actual result: %+v", result)
} else {
t.Logf("Actual result: nil")
}
})
}
func TestFilterEmpty(t *testing.T) {
tests := []struct {
name string
input []string
expected []string
}{
{
name: "empty slice",
input: []string{},
expected: []string{},
},
{
name: "no empty strings",
input: []string{"a", "b", "c"},
expected: []string{"a", "b", "c"},
},
{
name: "with empty strings",
input: []string{"", "a", "", "b", ""},
expected: []string{"a", "b"},
},
{
name: "all empty strings",
input: []string{"", "", ""},
expected: []string{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := filterEmpty(tt.input)
// Handle nil slice vs empty slice comparison
if len(result) == 0 && len(tt.expected) == 0 {
return // Both are effectively empty
}
if !reflect.DeepEqual(result, tt.expected) {
t.Errorf("Expected %+v, got %+v", tt.expected, result)
}
})
}
}
================================================
FILE: polyglot-utils/php/README.md
================================================
# PHP Implementation
URL pattern matching utility for PHP servers.
## Setup
Code tested on PHP 8.3.x.
```sh
php --version # Ensure PHP 7.0+ is available
# No third party dependencies needed. Just run the tests or use the function directly
```
## Running Tests
```sh
php test_preact_iso_url_pattern.php
```
## Usage
```php
<?php
require_once 'preact-iso-url-pattern.php';
$matches = preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts");
if ($matches) {
echo "User ID: " . $matches['params']->userId . "\n"; // Output: test@example.com
}
?>
```
## Function Signature
```php
function preactIsoUrlPatternMatch($url, $route, $matches = null): array|null
```
### Parameters
- `$url` (string): The URL path to match
- `$route` (string): The route pattern with parameters
- `$matches` (array, optional): Pre-existing matches array to extend
### Return Value
Returns an array on success, or `null` if no match:
```php
[
'params' => (object)['userId' => '123'],
'userId' => '123',
'rest' => '/additional/path' // Optional
]
```
**Note**: The `params` property is a PHP object (stdClass) to maintain consistency with JSON serialization, while the outer structure is an array.
## Route Patterns
| Pattern | Description | Example Result |
|---------|-------------|----------------|
| `/users/:id` | Named parameter | `['params' => (object)['id' => '123'], 'id' => '123']` |
| `/users/:id?` | Optional parameter | `['params' => (object)['id' => null], 'id' => null]` |
| `/files/:path+` | Required rest parameter | `['params' => (object)['path' => 'docs/readme.txt']]` |
| `/static/:path*` | Optional rest parameter | `['params' => (object)['path' => 'css/main.css']]` |
| `/static/*` | Anonymous wildcard | `['params' => (object)[], 'rest' => '/images/logo.png']` |
================================================
FILE: polyglot-utils/php/preact-iso-url-pattern.php
================================================
<?php
// Run program: php preact-iso-url-pattern.php
// Safe URL decode function with error handling
function safeUrldecode($str) {
if ($str === null || $str === '') {
return $str;
}
// urldecode in PHP generally doesn't throw exceptions,
// but we can add validation for malformed percent encoding
$decoded = urldecode($str);
// If the original contained a % but decoding didn't change much,
// it might be malformed, but PHP's urldecode is quite tolerant
return $decoded;
}
function preactIsoUrlPatternMatch($url, $route, $matches = null) {
if ($matches === null) {
$matches = ['params' => (object)[]];
}
$url = array_values(array_filter(explode('/', $url)));
$route = array_values(array_filter(explode('/', $route ?? '')));
for ($i = 0; $i < max(count($url), count($route)); $i++) {
preg_match('/^(:?)(.*?)([+*?]?)$/', $route[$i] ?? '', $parts);
$m = $parts[1] ?? '';
$param = $parts[2] ?? '';
$flag = $parts[3] ?? '';
$val = $url[$i] ?? null;
// segment match:
if (!$m && $param === $val) continue;
// /foo/* match
if (!$m && $val && $flag == '*') {
$decodedParts = array_map('safeUrldecode', array_slice($url, $i));
$matches['rest'] = '/' . implode('/', $decodedParts);
break;
}
// segment mismatch / missing required field:
if (!$m || (!$val && $flag != '?' && $flag != '*')) {
return null;
}
$rest = $flag == '+' || $flag == '*';
// rest (+/*) match:
if ($rest) {
$decodedParts = array_map('safeUrldecode', array_slice($url, $i));
$val = implode('/', $decodedParts) ?: null;
}
// normal/optional field:
elseif ($val) {
$val = safeUrldecode($url[$i]);
}
$matches['params']->$param = $val;
if (!isset($matches[$param])) {
$matches[$param] = $val;
}
if ($rest) break;
}
return $matches;
}
// Example usage:
// var_dump(preactIsoUrlPatternMatch("/foo/bar%20baz", "/foo/:param"));
// var_dump(preactIsoUrlPatternMatch("/foo/bar/baz", "/foo/*"));
// var_dump(preactIsoUrlPatternMatch("/foo", "/foo/:param?"));
// var_dump(preactIsoUrlPatternMatch("/foo/bar", "/bar/:param"));
// var_dump(preactIsoUrlPatternMatch('/users/test%40example.com/posts', '/users/:userId/posts'));
?>
================================================
FILE: polyglot-utils/php/test_preact_iso_url_pattern.php
================================================
<?php
/**
* Test suite for preact-iso-url-pattern.php - ported from Go tests
* Run with: php test_preact_iso_url_pattern.php
*/
require_once 'preact-iso-url-pattern.php';
class TestPreactIsoUrlPatternMatch {
private $tests = 0;
private $passed = 0;
private $failed = 0;
public function run() {
echo "Running PHP tests for preact-iso-url-pattern...\n\n";
// Run all test methods
$methods = get_class_methods($this);
foreach ($methods as $method) {
if (strpos($method, 'test_') === 0) {
$this->runTest($method);
}
}
echo "\n" . str_repeat("=", 60) . "\n";
echo "Test Results: {$this->passed} passed, {$this->failed} failed, {$this->tests} total\n";
if ($this->failed > 0) {
exit(1);
}
echo "All tests passed!\n";
}
private function runTest($methodName) {
$this->tests++;
try {
$this->$methodName();
$this->passed++;
echo ".";
} catch (Exception $e) {
$this->failed++;
echo "F";
echo "\nFAILED: $methodName - " . $e->getMessage() . "\n";
}
}
private function assertEqual($expected, $actual, $message = '') {
// Use JSON comparison for deep equality check (works for arrays and objects)
$expectedJson = json_encode($expected);
$actualJson = json_encode($actual);
if ($expectedJson !== $actualJson) {
$expectedStr = json_encode($expected, JSON_PRETTY_PRINT);
$actualStr = json_encode($actual, JSON_PRETTY_PRINT);
throw new Exception("Expected:\n$expectedStr\nGot:\n$actualStr\n$message");
}
}
private function assertNull($actual, $message = '') {
if ($actual !== null) {
$actualStr = json_encode($actual, JSON_PRETTY_PRINT);
throw new Exception("Expected null, got:\n$actualStr\n$message");
}
}
private function assertNotNull($actual, $message = '') {
if ($actual === null) {
throw new Exception("Expected non-null value, got null\n$message");
}
}
// Test methods start here
// Base route tests
public function test_base_route_exact_match() {
$result = preactIsoUrlPatternMatch("/", "/");
$expected = ['params' => (object)[]];
$this->assertEqual($expected, $result);
}
public function test_base_route_no_match() {
$result = preactIsoUrlPatternMatch("/user/1", "/");
$this->assertNull($result);
}
// Param route tests
public function test_param_route_match() {
$result = preactIsoUrlPatternMatch("/user/2", "/user/:id");
$expected = ['params' => (object)['id' => '2'], 'id' => '2'];
$this->assertEqual($expected, $result);
}
public function test_param_route_no_match() {
$result = preactIsoUrlPatternMatch("/", "/user/:id");
$this->assertNull($result);
}
// Rest segment tests
public function test_rest_segment_match() {
$result = preactIsoUrlPatternMatch("/user/foo", "/user/*");
$expected = ['params' => (object)[], 'rest' => '/foo'];
$this->assertEqual($expected, $result);
}
public function test_rest_segment_match_multiple_segments() {
$result = preactIsoUrlPatternMatch("/user/foo/bar/baz", "/user/*");
$expected = ['params' => (object)[], 'rest' => '/foo/bar/baz'];
$this->assertEqual($expected, $result);
}
public function test_rest_segment_no_match() {
$result = preactIsoUrlPatternMatch("/user", "/user/*");
$this->assertNull($result);
}
public function test_rest_segment_no_match_different_case() {
$result = preactIsoUrlPatternMatch("/", "/user/:id/*");
$this->assertNull($result);
}
// Param route with rest segment
public function test_param_with_rest_single_segment() {
$result = preactIsoUrlPatternMatch("/user/2/foo", "/user/:id/*");
$expected = ['params' => (object)['id' => '2'], 'id' => '2', 'rest' => '/foo'];
$this->assertEqual($expected, $result);
}
public function test_param_with_rest_multiple_segments() {
$result = preactIsoUrlPatternMatch("/user/2/foo/bar/bob", "/user/:id/*");
$expected = ['params' => (object)['id' => '2'], 'id' => '2', 'rest' => '/foo/bar/bob'];
$this->assertEqual($expected, $result);
}
public function test_param_with_rest_no_match() {
$result = preactIsoUrlPatternMatch("/", "/user/:id/*");
$this->assertNull($result);
}
// Optional param tests
public function test_optional_param_empty() {
$result = preactIsoUrlPatternMatch("/user", "/user/:id?");
$expected = ['params' => (object)['id' => null], 'id' => null];
$this->assertEqual($expected, $result);
}
public function test_optional_param_no_match_base() {
$result = preactIsoUrlPatternMatch("/", "/user/:id?");
$this->assertNull($result);
}
// Optional rest param tests (/:x*)
public function test_optional_rest_param_empty() {
$result = preactIsoUrlPatternMatch("/user", "/user/:id*");
$expected = ['params' => (object)['id' => null], 'id' => null];
$this->assertEqual($expected, $result);
}
public function test_optional_rest_param_with_segments() {
$result = preactIsoUrlPatternMatch("/user/foo/bar", "/user/:id*");
$expected = ['params' => (object)['id' => 'foo/bar'], 'id' => 'foo/bar'];
$this->assertEqual($expected, $result);
}
public function test_optional_param_no_match_base_duplicate() {
$result = preactIsoUrlPatternMatch("/", "/user/:id*");
$this->assertNull($result);
}
// Required rest param tests (/:x+)
public function test_required_rest_param_single_segment() {
$result = preactIsoUrlPatternMatch("/user/foo", "/user/:id+");
$expected = ['params' => (object)['id' => 'foo'], 'id' => 'foo'];
$this->assertEqual($expected, $result);
}
public function test_required_rest_param_multiple_segments() {
$result = preactIsoUrlPatternMatch("/user/foo/bar", "/user/:id+");
$expected = ['params' => (object)['id' => 'foo/bar'], 'id' => 'foo/bar'];
$this->assertEqual($expected, $result);
}
public function test_required_rest_param_empty_should_fail() {
$result = preactIsoUrlPatternMatch("/user", "/user/:id+");
$this->assertNull($result);
}
public function test_required_rest_param_root_mismatch() {
$result = preactIsoUrlPatternMatch("/", "/user/:id+");
$this->assertNull($result);
}
// Leading/trailing slashes
public function test_leading_trailing_slashes() {
$result = preactIsoUrlPatternMatch("/about-late/_SEGMENT1_/_SEGMENT2_/", "/about-late/:seg1/:seg2/");
$expected = ['params' => (object)['seg1' => '_SEGMENT1_', 'seg2' => '_SEGMENT2_'], 'seg1' => '_SEGMENT1_', 'seg2' => '_SEGMENT2_'];
$this->assertEqual($expected, $result);
}
// Additional tests that are not in test/node/router-match.test.js
// URL encoding tests
public function test_url_encoded_param() {
$result = preactIsoUrlPatternMatch("/foo/bar%20baz", "/foo/:param");
$expected = ['params' => (object)['param' => 'bar baz'], 'param' => 'bar baz'];
$this->assertEqual($expected, $result);
}
public function test_url_encoded_email_in_param() {
$result = preactIsoUrlPatternMatch("/users/test%40example.com/posts", "/users/:userId/posts");
$expected = ['params' => (object)['userId' => 'test@example.com'], 'userId' => 'test@example.com'];
$this->assertEqual($expected, $result);
}
// Complex rest segment with encoding
public function test_rest_segment_with_encoded_parts() {
$result = preactIsoUrlPatternMatch("/api/path/with%20spaces/and%2Fslashes", "/api/:path+");
$expected = ['params' => (object)['path' => 'path/with spaces/and/slashes'], 'path' => 'path/with spaces/and/slashes'];
$this->assertEqual($expected, $result);
}
// Edge cases
public function test_empty_route() {
$result = preactIsoUrlPatternMatch("/foo", "");
$this->assertNull($result);
}
public function test_empty_url_with_param() {
$result = preactIsoUrlPatternMatch("", "/:param");
$this->assertNull($result);
}
public function test_mixed_required_and_optional_params() {
$result = preactIsoUrlPatternMatch("/foo/bar", "/:required/:optional?");
$expected = ['params' => (object)['required' => 'foo', 'optional' => 'bar'], 'required' => 'foo', 'optional' => 'bar'];
$this->assertEqual($expected, $result);
}
public function test_mixed_required_and_optional_params_missing_optional() {
$result = preactIsoUrlPatternMatch("/foo", "/:required/:optional?");
$expected = ['params' => (object)['required' => 'foo', 'optional' => null], 'required' => 'foo', 'optional' => null];
$this->assertEqual($expected, $result);
}
// Test with pre-existing matches
public function test_pre_existing_matches_object() {
$matches = ['params' => (object)['existing' => 'value']];
$result = preactIsoUrlPatternMatch("/foo/bar", "/:first/:second", $matches);
$expected = ['params' => (object)['existing' => 'value', 'first' => 'foo', 'second' => 'bar'], 'first' => 'foo', 'second' => 'bar'];
$this->assertEqual($expected, $result);
}
// Complex nested paths
public function test_complex_nested_path_with_multiple_params() {
$result = preactIsoUrlPatternMatch("/api/v1/users/123/posts/456/comments", "/api/:version/users/:userId/posts/:postId/comments");
$expected = [
'params' => (object)['version' => 'v1', 'userId' => '123', 'postId' => '456'],
'version' => 'v1', 'userId' => '123', 'postId' => '456'
];
$this->assertEqual($expected, $result);
}
public function test_route_longer_than_url_required_param_missing() {
$result = preactIsoUrlPatternMatch("/api", "/api/:version/:resource");
$this->assertNull($result);
}
public function test_route_longer_than_url_optional_param() {
$result = preactIsoUrlPatternMatch("/api", "/api/:version?");
$expected = ['params' => (object)['version' => null], 'version' => null];
$this->assertEqual($expected, $result);
}
public function test_multiple_slashes_in_url_should_be_normalized() {
$result = preactIsoUrlPatternMatch("//user//123//", "/user/:id");
$expected = ['params' => (object)['id' => '123'], 'id' => '123'];
$this->assertEqual($expected, $result);
}
public function test_route_with_multiple_slashes() {
$result = preactIsoUrlPatternMatch("/user/123", "//user//:id//");
$expected = ['params' => (object)['id' => '123'], 'id' => '123'];
$this->assertEqual($expected, $result);
}
// Additional URL encoding tests
public function test_complex_url_encoding_in_rest_params() {
$result = preactIsoUrlPatternMatch("/files/folder%2Fsubfolder/file%20name.txt", "/files/:path+");
$expected = ['params' => (object)['path' => 'folder/subfolder/file name.txt'], 'path' => 'folder/subfolder/file name.txt'];
$this->assertEqual($expected, $result);
}
public function test_special_characters_encoded_in_url() {
$result = preactIsoUrlPatternMatch("/search/query%3F%2B%23%26test", "/search/:query");
$expected = ['params' => (object)['query' => 'query?+#&test'], 'query' => 'query?+#&test'];
$this->assertEqual($expected, $result);
}
public function test_unicode_characters_encoded() {
$result = preactIsoUrlPatternMatch("/user/Jos%C3%A9", "/user/:name");
$expected = ['params' => (object)['name' => 'José'], 'name' => 'José'];
$this->assertEqual($expected, $result);
}
public function test_empty_segments_in_middle_of_url() {
$result = preactIsoUrlPatternMatch("/api//v1//users", "/api/v1/users");
$expected = ['params' => (object)[]];
$this->assertEqual($expected, $result);
}
public function test_route_with_only_wildcards() {
$result = preactIsoUrlPatternMatch("/anything/goes/here", "*");
$expected = ['params' => (object)[], 'rest' => '/anything/goes/here'];
$this->assertEqual($expected, $result);
}
// URL decoding error handling tests
public function test_malformed_percent_encoding_simple_param() {
// Test malformed percent encoding in simple param - should not crash
$result = preactIsoUrlPatternMatch("/user/test%", "/user/:id");
// Should either work or return null, but not crash
$this->assertNotNull($result);
}
public function test_malformed_percent_encoding_rest_param() {
// Test malformed percent encoding in rest param - should not crash
$result = preactIsoUrlPatternMatch("/files/test%/file", "/files/:path+");
// Should either work or return null, but not crash
$this->assertNotNull($result);
}
public function test_invalid_unicode_sequence() {
// Test invalid unicode sequence - should not crash
$result = preactIsoUrlPatternMatch("/user/test%C3", "/user/:id");
// Should either work or return null, but not crash
$this->assertNotNull($result);
}
}
// Run tests if this file is executed directly
if (php_sapi_name() === 'cli') {
$test = new TestPreactIsoUrlPatternMatch();
$test->run();
}
?>
================================================
FILE: polyglot-utils/python/README.md
================================================
# Python Implementation
URL pattern matching utility for Python servers.
## Setup
Code tested on Python 3.12.x.
No external dependencies required - uses only Python standard library.
```sh
python3 --version # Ensure Python 3.6+ is available
# No third party dependencies needed. Just run the tests or use the function directly
```
## Running Tests
```sh
python3 test_preact_iso_url_pattern.py
```
## Usage
```python
from preact_iso_url_pattern import preact_iso_url_pattern_match
matches = preact_iso_url_pattern_match("/users/test%40example.com/posts", "/users/:userId/posts")
if matches:
print(f"User ID: {matches['params']['userId']}") # Output: test@example.com
```
## Function Signature
```python
def preact_iso_url_pattern_match(url, route, matches=None) -> dict | None
```
### Parameters
- `url` (str): The URL path to match
- `route` (str): The route pattern with parameters
- `matches` (dict, optional): Pre-existing matches dictionary to extend
### Return Value
Returns a dictionary on success, or `None` if no match:
```python
{
"params": {"userId": "123"},
"userId": "123",
"rest": "/additional/path" # Optional
}
```
## Route Patterns
| Pattern | Description | Example Result |
|---------|-------------|----------------|
| `/users/:id` | Named parameter | `{"params": {"id": "123"}, "id": "123"}` |
| `/users/:id?` | Optional parameter | `{"params": {"id": None}, "id": None}` |
| `/files/:path+` | Required rest parameter | `{"params": {"path": "docs/readme.txt"}}` |
| `/static/:path*` | Optional rest parameter | `{"params": {"path": "css/main.css"}}` |
| `/static/*` | Anonymous wildcard | `{"params": {}, "rest": "/images/logo.png"}` |
================================================
FILE: polyglot-utils/python/preact_iso_url_pattern.py
================================================
# Run program: python3 preact-iso-url-pattern.py
from urllib.parse import unquote
# Safe URL decode function with error handling
def safe_unquote(s):
if s is None or s == '':
return s
try:
return unquote(s)
except UnicodeDecodeError:
# If unquote fails due to malformed encoding, return original string
return s
def preact_iso_url_pattern_match(url, route, matches=None):
# Initialize matches object if not provided
if matches is None:
matches = {'params': {}}
url = list(filter(None, url.split('/')))
route = list(filter(None, (route or '').split('/')))
for i in range(max(len(url), len(route))):
m, param, flag = '', '', ''
if i < len(route):
parts = route[i].split(':')
m = ':' if len(parts) > 1 else ''
param = parts[-1]
flag = ''
if param and param[-1] in '+*?':
flag = param[-1]
param = param[:-1]
val = url[i] if i < len(url) else None
# segment match:
if not m and param == val:
continue
# /foo/* match
if not m and val and flag == '*':
# Store remaining path segments in rest
matches['rest'] = '/' + '/'.join(map(safe_unquote, url[i:]))
break
# segment mismatch / missing required field:
if not m or (not val and flag != '?' and flag != '*'):
return None
rest = flag in ('+', '*')
# rest (+/*) match:
if rest:
val = '/'.join(map(safe_unquote, url[i:])) or None
# normal/optional field:
elif val:
val = safe_unquote(val)
# Store parameter values in matches
matches['params'][param] = val
if param not in matches:
matches[param] = val
if rest:
break
return matches
# Example usage:
# print(preact_iso_url_pattern_match("/foo/bar%20baz", "/foo/:param"))
# print(preact_iso_url_pattern_match("/foo/bar/baz", "/foo/*"))
# print(preact_iso_url_pattern_match("/foo", "/foo/:param?"))
# print(preact_iso_url_pattern_match("/foo/bar", "/bar/:param"))
# print(preact_iso_url_pattern_match('/users/test%40example.com/posts', '/users/:userId/posts'))
================================================
FILE: polyglot-utils/python/test_preact_iso_url_pattern.py
================================================
#!/usr/bin/env python3
"""Test suite for preact_iso_url_pattern.py - ported from Go tests"""
import unittest
from preact_iso_url_pattern import preact_iso_url_pattern_match
class TestPreactIsoUrlPatternMatch(unittest.TestCase):
# Base route tests
def test_base_route_exact_match(self):
"""Base route - exact match"""
result = preact_iso_url_pattern_match("/", "/")
expected = {'params': {}}
self.assertEqual(result, expected)
def test_base_route_no_match(self):
"""Base route - no match"""
result = preact_iso_url_pattern_match("/user/1", "/")
self.assertIsNone(result)
# Param route tests
def test_param_route_match(self):
"""Param route - match"""
result = preact_iso_url_pattern_match("/user/2", "/user/:id")
expected = {'params': {'id': '2'}, 'id': '2'}
self.assertEqual(result, expected)
def test_param_route_no_match(self):
"""Param route - no match"""
result = preact_iso_url_pattern_match("/", "/user/:id")
self.assertIsNone(result)
# Rest segment tests
def test_rest_segment_match(self):
"""Rest segment - match"""
result = preact_iso_url_pattern_match("/user/foo", "/user/*")
expected = {'params': {}, 'rest': '/foo'}
self.assertEqual(result, expected)
def test_rest_segment_match_multiple_segments(self):
"""Rest segment - match multiple segments"""
result = preact_iso_url_pattern_match("/user/foo/bar/baz", "/user/*")
expected = {'params': {}, 'rest': '/foo/bar/baz'}
self.assertEqual(result, expected)
def test_rest_segment_no_match(self):
"""Rest segment - no match"""
result = preact_iso_url_pattern_match("/user", "/user/*")
self.assertIsNone(result)
def test_rest_segment_no_match_different_case(self):
"""Rest segment - no match different case"""
result = preact_iso_url_pattern_match("/", "/user/:id/*")
self.assertIsNone(result)
# Param route with rest segment
def test_param_with_rest_single_segment(self):
"""Param with rest - single segment"""
result = preact_iso_url_pattern_match("/user/2/foo", "/user/:id/*")
expected = {'params': {'id': '2'}, 'id': '2', 'rest': '/foo'}
self.assertEqual(result, expected)
def test_param_with_rest_multiple_segments(self):
"""Param with rest - multiple segments"""
result = preact_iso_url_pattern_match("/user/2/foo/bar/bob", "/user/:id/*")
expected = {'params': {'id': '2'}, 'id': '2', 'rest': '/foo/bar/bob'}
self.assertEqual(result, expected)
def test_param_with_rest_no_match(self):
"""Param with rest - no match"""
result = preact_iso_url_pattern_match("/", "/user/:id/*")
self.assertIsNone(result)
# Optional param tests
def test_optional_param_empty(self):
"""Optional param - empty"""
result = preact_iso_url_pattern_match("/user", "/user/:id?")
expected = {'params': {'id': None}, 'id': None}
self.assertEqual(result, expected)
def test_optional_param_no_match_base(self):
"""Optional param - no match base"""
result = preact_iso_url_pattern_match("/", "/user/:id?")
self.assertIsNone(result)
# Optional rest param tests (/:x*)
def test_optional_rest_param_empty(self):
"""Optional rest param - empty"""
result = preact_iso_url_pattern_match("/user", "/user/:id*")
expected = {'params': {'id': None}, 'id': None}
self.assertEqual(result, expected)
def test_optional_rest_param_with_segments(self):
"""Optional rest param - with segments"""
result = preact_iso_url_pattern_match("/user/foo/bar", "/user/:id*")
expected = {'params': {'id': 'foo/bar'}, 'id': 'foo/bar'}
self.assertEqual(result, expected)
def test_optional_param_no_match_base_duplicate(self):
"""Optional param - no match base duplicate"""
result = preact_iso_url_pattern_match("/", "/user/:id*")
self.assertIsNone(result)
# Required rest param tests (/:x+)
def test_required_rest_param_single_segment(self):
"""Required rest param - single segment"""
result = preact_iso_url_pattern_match("/user/foo", "/user/:id+")
expected = {'params': {'id': 'foo'}, 'id': 'foo'}
self.assertEqual(result, expected)
def test_required_rest_param_multiple_segments(self):
"""Required rest param - multiple segments"""
result = preact_iso_url_pattern_match("/user/foo/bar", "/user/:id+")
expected = {'params': {'id': 'foo/bar'}, 'id': 'foo/bar'}
self.assertEqual(result, expected)
def test_required_rest_param_empty_should_fail(self):
"""Required rest param - empty (should fail)"""
result = preact_iso_url_pattern_match("/user", "/user/:id+")
self.assertIsNone(result)
def test_required_rest_param_root_mismatch(self):
"""Required rest param - root mismatch"""
result = preact_iso_url_pattern_match("/", "/user/:id+")
self.assertIsNone(result)
# Leading/trailing slashes
def test_leading_trailing_slashes(self):
"""Leading/trailing slashes"""
result = preact_iso_url_pattern_match("/about-late/_SEGMENT1_/_SEGMENT2_/", "/about-late/:seg1/:seg2/")
expected = {'params': {'seg1': '_SEGMENT1_', 'seg2': '_SEGMENT2_'}, 'seg1': '_SEGMENT1_', 'seg2': '_SEGMENT2_'}
self.assertEqual(result, expected)
# Additional tests that are not in test/node/router-match.test.js
# URL encoding tests
def test_url_encoded_param(self):
"""URL encoded param"""
result = preact_iso_url_pattern_match("/foo/bar%20baz", "/foo/:param")
expected = {'params': {'param': 'bar baz'}, 'param': 'bar baz'}
self.assertEqual(result, expected)
def test_url_encoded_email_in_param(self):
"""URL encoded email in param"""
result = preact_iso_url_pattern_match("/users/test%40example.com/posts", "/users/:userId/posts")
expected = {'params': {'userId': 'test@example.com'}, 'userId': 'test@example.com'}
self.assertEqual(result, expected)
# Complex rest segment with encoding
def test_rest_segment_with_encoded_parts(self):
"""Rest segment with encoded parts"""
result = preact_iso_url_pattern_match("/api/path/with%20spaces/and%2Fslashes", "/api/:path+")
expected = {'params': {'path': 'path/with spaces/and/slashes'}, 'path': 'path/with spaces/and/slashes'}
self.assertEqual(result, expected)
# Edge cases
def test_empty_route(self):
"""Empty route"""
result = preact_iso_url_pattern_match("/foo", "")
self.assertIsNone(result)
def test_empty_url_with_param(self):
"""Empty url with param"""
result = preact_iso_url_pattern_match("", "/:param")
self.assertIsNone(result)
def test_mixed_required_and_optional_params(self):
"""Mixed required and optional params"""
result = preact_iso_url_pattern_match("/foo/bar", "/:required/:optional?")
expected = {'params': {'required': 'foo', 'optional': 'bar'}, 'required': 'foo', 'optional': 'bar'}
self.assertEqual(result, expected)
def test_mixed_required_and_optional_params_missing_optional(self):
"""Mixed required and optional params - missing optional"""
result = preact_iso_url_pattern_match("/foo", "/:required/:optional?")
expected = {'params': {'required': 'foo', 'optional': None}, 'required': 'foo', 'optional': None}
self.assertEqual(result, expected)
# Test with pre-existing matches
def test_pre_existing_matches_object(self):
"""Pre-existing matches object"""
matches = {'params': {'existing': 'value'}}
result = preact_iso_url_pattern_match("/foo/bar", "/:first/:second", matches)
expected = {'params': {'existing': 'value', 'first': 'foo', 'second': 'bar'}, 'first': 'foo', 'second': 'bar'}
self.assertEqual(result, expected)
# Complex nested paths
def test_complex_nested_path_with_multiple_params(self):
"""Complex nested path with multiple params"""
result = preact_iso_url_pattern_match("/api/v1/users/123/posts/456/comments", "/api/:version/users/:userId/posts/:postId/comments")
expected = {
'params': {'version': 'v1', 'userId': '123', 'postId': '456'},
'version': 'v1', 'userId': '123', 'postId': '456'
}
self.assertEqual(result, expected)
def test_route_longer_than_url_required_param_missing(self):
"""Route longer than URL - required param missing"""
result = preact_iso_url_pattern_match("/api", "/api/:version/:resource")
self.assertIsNone(result)
def test_route_longer_than_url_optional_param(self):
"""Route longer than URL - optional param"""
result = preact_iso_url_pattern_match("/api", "/api/:version?")
expected = {'params': {'version': None}, 'version': None}
self.assertEqual(result, expected)
def test_multiple_slashes_in_url_should_be_normalized(self):
"""Multiple slashes in URL should be normalized"""
result = preact_iso_url_pattern_match("//user//123//", "/user/:id")
expected = {'params': {'id': '123'}, 'id': '123'}
self.assertEqual(result, expected)
def test_route_with_multiple_slashes(self):
"""Route with multiple slashes"""
result = preact_iso_url_pattern_match("/user/123", "//user//:id//")
expected = {'params': {'id': '123'}, 'id': '123'}
self.assertEqual(result, expected)
def test_complex_url_encoding_in_rest_params(self):
"""Complex URL encoding in rest params"""
result = preact_iso_url_pattern_match("/files/folder%2Fsubfolder/file%20name.txt", "/files/:path+")
expected = {'params': {'path': 'folder/subfolder/file name.txt'}, 'path': 'folder/subfolder/file name.txt'}
self.assertEqual(result, expected)
def test_special_characters_encoded_in_url(self):
"""Special characters encoded in URL"""
result = preact_iso_url_pattern_match("/search/query%3F%2B%23%26test", "/search/:query")
expected = {'params': {'query': 'query?+#&test'}, 'query': 'query?+#&test'}
self.assertEqual(result, expected)
def test_unicode_characters_encoded(self):
"""Unicode characters encoded"""
result = preact_iso_url_pattern_match("/user/Jos%C3%A9", "/user/:name")
expected = {'params': {'name': 'José'}, 'name': 'José'}
self.assertEqual(result, expected)
def test_empty_segments_in_middle_of_url(self):
"""Empty segments in middle of URL"""
result = preact_iso_url_pattern_match("/api//v1//users", "/api/v1/users")
expected = {'params': {}}
self.assertEqual(result, expected)
def test_route_with_only_wildcards(self):
"""Route with only wildcards"""
result = preact_iso_url_pattern_match("/anything/goes/here", "*")
expected = {'params': {}, 'rest': '/anything/goes/here'}
self.assertEqual(result, expected)
class TestUrlDecodingErrorHandling(unittest.TestCase):
"""Tests specifically for URL decoding error scenarios"""
def test_malformed_percent_encoding_simple_param(self):
"""Test malformed percent encoding in simple param - should not crash"""
# This should handle malformed encoding gracefully
result = preact_iso_url_pattern_match("/user/test%", "/user/:id")
# Should either work or return None, but not crash
self.assertIsNotNone(result)
def test_malformed_percent_encoding_rest_param(self):
"""Test malformed percent encoding in rest param - should not crash"""
result = preact_iso_url_pattern_match("/files/test%/file", "/files/:path+")
# Should either work or return None, but not crash
self.assertIsNotNone(result)
def test_invalid_unicode_sequence(self):
"""Test invalid unicode sequence - should not crash"""
result = preact_iso_url_pattern_match("/user/test%C3", "/user/:id")
# Should either work or return None, but not crash
self.assertIsNotNone(result)
if __name__ == '__main__':
# Run tests with verbose output
unittest.main(verbosity=2)
================================================
FILE: polyglot-utils/ruby/README.md
================================================
# Ruby Implementation
URL pattern matching utility for Ruby servers.
## Setup
Code tested on ruby 3.2.x.
```sh
ruby --version # Ensure Ruby 2.0+ is available
# No third party dependencies needed. Just run the tests or use the function directly
```
## Running Tests
```sh
ruby test_preact_iso_url_pattern.rb
```
## Usage
```ruby
require_relative 'preact-iso-url-pattern'
matches = preact_iso_url_pattern_match("/users/test%40example.com/posts", "/users/:userId/posts")
if matches
puts "User ID: #{matches['params']['userId']}" # Output: test@example.com
end
```
## Function Signature
```ruby
def preact_iso_url_pattern_match(url, route, matches = nil) -> Hash | nil
```
### Parameters
- `url` (String): The URL path to match
- `route` (String): The route pattern with parameters
- `matches` (Hash, optional): Pre-existing matches hash to extend
### Return Value
Returns a Hash on success, or `nil` if no match:
```ruby
{
'params' => { 'userId' => '123' },
'userId' => '123',
'rest' => '/additional/path' # Optional
}
```
## Route Patterns
| Pattern | Description | Example Result |
|---------|-------------|----------------|
| `/users/:id` | Named parameter | `{'params' => {'id' => '123'}, 'id' => '123'}` |
| `/users/:id?` | Optional parameter | `{'params' => {'id' => nil}, 'id' => nil}` |
| `/files/:path+` | Required rest parameter | `{'params' => {'path' => 'docs/readme.txt'}}` |
| `/static/:path*` | Optional rest parameter | `{'params' => {'path' => 'css/main.css'}}` |
| `/static/*` | Anonymous wildcard | `{'params' => {}, 'rest' => '/images/logo.png'}` |
================================================
FILE: polyglot-utils/ruby/preact-iso-url-pattern.rb
================================================
# Run program: ruby preact-iso-url-pattern.rb
require 'cgi'
# Safe URL decode function with error handling
def safe_cgi_unescape(str)
return str if str.nil? || str.empty?
begin
CGI.unescape(str)
rescue ArgumentError
# If CGI.unescape fails due to malformed encoding, return original string
str
end
end
def preact_iso_url_pattern_match(url, route, matches = nil)
matches ||= { 'params' => {} }
url = url.split('/').reject(&:empty?)
route = (route || '').split('/').reject(&:empty?)
(0...[url.length, route.length].max).each do |i|
m, param, flag = route[i]&.match(/^(:?)(.*?)([+*?]?)$/)&.captures || ['', '', '']
val = url[i]
# segment match:
next if m.empty? && param == val
# /foo/* match
if m.empty? && val && flag == '*'
decoded_parts = url[i..].map { |part| safe_cgi_unescape(part) }
matches['rest'] = '/' + decoded_parts.join('/')
break
end
# segment mismatch / missing required field:
return nil if m.empty? || (!val && flag != '?' && flag != '*')
rest = flag == '+' || flag == '*'
# rest (+/*) match:
if rest
decoded_parts = url[i..].map { |part| safe_cgi_unescape(part) }
joined = decoded_parts.join('/')
val = joined.empty? ? nil : joined
# normal/optional field:
elsif val
val = safe_cgi_unescape(val)
end
matches['params'][param] = val
matches[param] = val unless matches.key?(param)
break if rest
end
matches
end
# Example usage:
# puts preact_iso_url_pattern_match("/foo/bar%20baz", "/foo/:param")
# puts preact_iso_url_pattern_match("/foo/bar/baz", "/foo/*")
# puts preact_iso_url_pattern_match("/foo", "/foo/:param?")
# puts preact_iso_url_pattern_match("/foo/bar", "/bar/:param")
# puts preact_iso_url_pattern_match('/users/test%40example.com/posts', '/users/:userId/posts')
================================================
FILE: polyglot-utils/ruby/test_preact_iso_url_pattern.rb
================================================
#!/usr/bin/env ruby
# Test suite for preact-iso-url-pattern.rb - ported from Go tests
require 'minitest/autorun'
require_relative 'preact-iso-url-pattern'
class TestPreactIsoUrlPatternMatch < Minitest::Test
# Base route tests
def test_base_route_exact_match
# Base route - exact match
result = preact_iso_url_pattern_match("/", "/")
expected = { 'params' => {} }
assert_equal expected, result
end
def test_base_route_no_match
# Base route - no match
result = preact_iso_url_pattern_match("/user/1", "/")
assert_nil result
end
# Param route tests
def test_param_route_match
# Param route - match
result = preact_iso_url_pattern_match("/user/2", "/user/:id")
expected = { 'params' => { 'id' => '2' }, 'id' => '2' }
assert_equal expected, result
end
def test_param_route_no_match
# Param route - no match
result = preact_iso_url_pattern_match("/", "/user/:id")
assert_nil result
end
# Rest segment tests
def test_rest_segment_match
# Rest segment - match
result = preact_iso_url_pattern_match("/user/foo", "/user/*")
expected = { 'params' => {}, 'rest' => '/foo' }
assert_equal expected, result
end
def test_rest_segment_match_multiple_segments
# Rest segment - match multiple segments
result = preact_iso_url_pattern_match("/user/foo/bar/baz", "/user/*")
expected = { 'params' => {}, 'rest' => '/foo/bar/baz' }
assert_equal expected, result
end
def test_rest_segment_no_match
# Rest segment - no match
result = preact_iso_url_pattern_match("/user", "/user/*")
assert_nil result
end
def test_rest_segment_no_match_different_case
# Rest segment - no match different case
result = preact_iso_url_pattern_match("/", "/user/:id/*")
assert_nil result
end
# Param route with rest segment
def test_param_with_rest_single_segment
# Param with rest - single segment
result = preact_iso_url_pattern_match("/user/2/foo", "/user/:id/*")
expected = { 'params' => { 'id' => '2' }, 'id' => '2', 'rest' => '/foo' }
assert_equal expected, result
end
def test_param_with_rest_multiple_segments
# Param with rest - multiple segments
result = preact_iso_url_pattern_match("/user/2/foo/bar/bob", "/user/:id/*")
expected = { 'params' => { 'id' => '2' }, 'id' => '2', 'rest' => '/foo/bar/bob' }
assert_equal expected, result
end
def test_param_with_rest_no_match
# Param with rest - no match
result = preact_iso_url_pattern_match("/", "/user/:id/*")
assert_nil result
end
# Optional param tests
def test_optional_param_empty
# Optional param - empty
result = preact_iso_url_pattern_match("/user", "/user/:id?")
expected = { 'params' => { 'id' => nil }, 'id' => nil }
assert_equal expected, result
end
def test_optional_param_no_match_base
# Optional param - no match base
result = preact_iso_url_pattern_match("/", "/user/:id?")
assert_nil result
end
# Optional rest param tests (/:x*)
def test_optional_rest_param_empty
# Optional rest param - empty
result = preact_iso_url_pattern_match("/user", "/user/:id*")
expected = { 'params' => { 'id' => nil }, 'id' => nil }
assert_equal expected, result
end
def test_optional_rest_param_with_segments
# Optional rest param - with segments
result = preact_iso_url_pattern_match("/user/foo/bar", "/user/:id*")
expected = { 'params' => { 'id' => 'foo/bar' }, 'id' => 'foo/bar' }
assert_equal expected, result
end
def test_optional_param_no_match_base_duplicate
# Optional param - no match base duplicate
result = preact_iso_url_pattern_match("/", "/user/:id*")
assert_nil result
end
# Required rest param tests (/:x+)
def test_required_rest_param_single_segment
# Required rest param - single segment
result = preact_iso_url_pattern_match("/user/foo", "/user/:id+")
expected = { 'params' => { 'id' => 'foo' }, 'id' => 'foo' }
assert_equal expected, result
end
def test_required_rest_param_multiple_segments
# Required rest param - multiple segments
result = preact_iso_url_pattern_match("/user/foo/bar", "/user/:id+")
expected = { 'params' => { 'id' => 'foo/bar' }, 'id' => 'foo/bar' }
assert_equal expected, result
end
def test_required_rest_param_empty_should_fail
# Required rest param - empty (should fail)
result = preact_iso_url_pattern_match("/user", "/user/:id+")
assert_nil result
end
def test_required_rest_param_root_mismatch
# Required rest param - root mismatch
result = preact_iso_url_pattern_match("/", "/user/:id+")
assert_nil result
end
# Leading/trailing slashes
def test_leading_trailing_slashes
# Leading/trailing slashes
result = preact_iso_url_pattern_match("/about-late/_SEGMENT1_/_SEGMENT2_/", "/about-late/:seg1/:seg2/")
expected = { 'params' => { 'seg1' => '_SEGMENT1_', 'seg2' => '_SEGMENT2_' }, 'seg1' => '_SEGMENT1_', 'seg2' => '_SEGMENT2_' }
assert_equal expected, result
end
# Additional tests that are not in test/node/router-match.test.js
# URL encoding tests
def test_url_encoded_param
# URL encoded param
result = preact_iso_url_pattern_match("/foo/bar%20baz", "/foo/:param")
expected = { 'params' => { 'param' => 'bar baz' }, 'param' => 'bar baz' }
assert_equal expected, result
end
def test_url_encoded_email_in_param
# URL encoded email in param
result = preact_iso_url_pattern_match("/users/test%40example.com/posts", "/users/:userId/posts")
expected = { 'params' => { 'userId' => 'test@example.com' }, 'userId' => 'test@example.com' }
assert_equal expected, result
end
# Complex rest segment with encoding
def test_rest_segment_with_encoded_parts
# Rest segment with encoded parts
result = preact_iso_url_pattern_match("/api/path/with%20spaces/and%2Fslashes", "/api/:path+")
expected = { 'params' => { 'path' => 'path/with spaces/and/slashes' }, 'path' => 'path/with spaces/and/slashes' }
assert_equal expected, result
end
# Edge cases
def test_empty_route
# Empty route
result = preact_iso_url_pattern_match("/foo", "")
assert_nil result
end
def test_empty_url_with_param
# Empty url with param
result = preact_iso_url_pattern_match("", "/:param")
assert_nil result
end
def test_mixed_required_and_optional_params
# Mixed required and optional params
result = preact_iso_url_pattern_match("/foo/bar", "/:required/:optional?")
expected = { 'params' => { 'required' => 'foo', 'optional' => 'bar' }, 'required' => 'foo', 'optional' => 'bar' }
assert_equal expected, result
end
def test_mixed_required_and_optional_params_missing_optional
# Mixed required and optional params - missing optional
result = preact_iso_url_pattern_match("/foo", "/:required/:optional?")
expected = { 'params' => { 'required' => 'foo', 'optional' => nil }, 'required' => 'foo', 'optional' => nil }
assert_equal expected, result
end
# Test with pre-existing matches
def test_pre_existing_matches_object
# Pre-existing matches object
matches = { 'params' => { 'existing' => 'value' } }
result = preact_iso_url_pattern_match("/foo/bar", "/:first/:second", matches)
expected = { 'params' => { 'existing' => 'value', 'first' => 'foo', 'second' => 'bar' }, 'first' => 'foo', 'second' => 'bar' }
assert_equal expected, result
end
# Complex nested paths
def test_complex_nested_path_with_multiple_params
# Complex nested path with multiple params
result = preact_iso_url_pattern_match("/api/v1/users/123/posts/456/comments", "/api/:version/users/:userId/posts/:postId/comments")
expected = {
'params' => { 'version' => 'v1', 'userId' => '123', 'postId' => '456' },
'version' => 'v1', 'userId' => '123', 'postId' => '456'
}
assert_equal expected, result
end
def test_route_longer_than_url_required_param_missing
# Route longer than URL - required param missing
result = preact_iso_url_pattern_match("/api", "/api/:version/:resource")
assert_nil result
end
def test_route_longer_than_url_optional_param
# Route longer than URL - optional param
result = preact_iso_url_pattern_match("/api", "/api/:version?")
expected = { 'params' => { 'version' => nil }, 'version' => nil }
assert_equal expected, result
end
def test_multiple_slashes_in_url_should_be_normalized
# Multiple slashes in URL should be normalized
result = preact_iso_url_pattern_match("//user//123//", "/user/:id")
expected = { 'params' => { 'id' => '123' }, 'id' => '123' }
assert_equal expected, result
end
def test_route_with_multiple_slashes
# Route with multiple slashes
result = preact_iso_url_pattern_match("/user/123", "//user//:id//")
expected = { 'params' => { 'id' => '123' }, 'id' => '123' }
assert_equal expected, result
end
def test_rest_param_with_single_character
# Rest param with single character
result = preact_iso_url_pattern_match("/a/b", "/:x+")
expected = { 'params' => { 'x' => 'a/b' }, 'x' => 'a/b' }
assert_equal expected, result
end
def test_complex_url_encoding_in_rest_params
# Complex URL encoding in rest params
result = preact_iso_url_pattern_match("/files/folder%2Fsubfolder/file%20name.txt", "/files/:path+")
expected = { 'params' => { 'path' => 'folder/subfolder/file name.txt' }, 'path' => 'folder/subfolder/file name.txt' }
assert_equal expected, result
end
def test_special_characters_encoded_in_url
# Special characters encoded in URL
result = preact_iso_url_pattern_match("/search/query%3F%2B%23%26test", "/search/:query")
expected = { 'params' => { 'query' => 'query?+#&test' }, 'query' => 'query?+#&test' }
assert_equal expected, result
end
def test_unicode_characters_encoded
# Unicode characters encoded
result = preact_iso_url_pattern_match("/user/Jos%C3%A9", "/user/:name")
expected = { 'params' => { 'name' => 'José' }, 'name' => 'José' }
assert_equal expected, result
end
def test_empty_segments_in_middle_of_url
# Empty segments in middle of URL
result = preact_iso_url_pattern_match("/api//v1//users", "/api/v1/users")
expected = { 'params' => {} }
assert_equal expected, result
end
def test_route_with_only_wildcards
# Route with only wildcards
result = preact_iso_url_pattern_match("/anything/goes/here", "*")
expected = { 'params' => {}, 'rest' => '/anything/goes/here' }
assert_equal expected, result
end
end
class TestUrlDecodingErrorHandling < Minitest::Test
# Tests specifically for URL decoding error scenarios
def test_malformed_percent_encoding_simple_param
# Test malformed percent encoding in simple param - should not crash
# This should handle malformed encoding gracefully
result = preact_iso_url_pattern_match("/user/test%", "/user/:id")
# Should either work or return nil, but not crash
refute_nil result
end
def test_malformed_percent_encoding_rest_param
# Test malformed percent encoding in rest param - should not crash
result = preact_iso_url_pattern_match("/files/test%/file", "/files/:path+")
# Should either work or return nil, but not crash
refute_nil result
end
def test_invalid_unicode_sequence
# Test invalid unicode sequence - should not crash
result = preact_iso_url_pattern_match("/user/test%C3", "/user/:id")
# Should either work or return nil, but not crash
refute_nil result
end
end
# Run tests if this file is executed directly
if __FILE__ == $0
puts "Running Ruby tests for preact-iso-url-pattern..."
end
================================================
FILE: polyglot-utils/run_tests.sh
================================================
#!/bin/bash
# Preact ISO URL Pattern Matching - Test Runner
# Runs tests for all language implementations
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# Track test results
TOTAL_LANGUAGES=4
PASSED_LANGUAGES=0
echo "========================================"
echo "Preact ISO URL Pattern - Test Runner"
echo "========================================"
echo
# Function to run a test and track results
run_test() {
local language=$1
local directory=$2
local command=$3
local description=$4
echo -e "${BLUE}Testing $language${NC} ($description)"
echo "----------------------------------------"
# Change to test directory and run command
if cd "$directory" 2>/dev/null; then
if eval "$command"; then
echo -e "${GREEN}$language tests PASSED${NC}"
((PASSED_LANGUAGES++))
else
echo -e "${RED}$language tests FAILED${NC}"
fi
else
echo -e "${RED}$language tests FAILED - Directory not found${NC}"
fi
echo
# Return to script directory
cd "$SCRIPT_DIR" 2>/dev/null || true
}
# Get the script directory to ensure we're in the right place
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
# Run tests for each language
run_test "Go" "go" "go test -v" "Static typing with struct returns"
run_test "Python" "python" "python3 test_preact_iso_url_pattern.py" "Dictionary-based with optional typing"
run_test "Ruby" "ruby" "ruby test_preact_iso_url_pattern.rb" "Hash-based with flexible syntax"
run_test "PHP" "php" "php test_preact_iso_url_pattern.php" "Mixed array/object approach"
# Summary
echo "========================================"
echo "Test Summary"
echo "========================================"
if [ $PASSED_LANGUAGES -eq $TOTAL_LANGUAGES ]; then
echo -e "${GREEN}All $TOTAL_LANGUAGES language implementations passed their tests!${NC}"
echo -e "${GREEN}Total tests across all languages: 204 (51 × 4)${NC}"
exit 0
else
echo -e "${RED}$PASSED_LANGUAGES/$TOTAL_LANGUAGES language implementations passed${NC}"
echo -e "${RED}$(($TOTAL_LANGUAGES - $PASSED_LANGUAGES)) language(s) failed${NC}"
exit 1
fi
================================================
FILE: src/hydrate.d.ts
================================================
import { ComponentChild, ContainerNode } from 'preact';
export default function hydrate(jsx: ComponentChild, parent?: ContainerNode): void;
================================================
FILE: src/hydrate.js
================================================
import { render, hydrate as hydrativeRender } from 'preact';
let initialized;
/** @type {typeof hydrativeRender} */
export default function hydrate(jsx, parent) {
if (typeof window === 'undefined') return;
let isodata = document.querySelector('script[type=isodata]');
// @ts-ignore-next
parent = parent || (isodata && isodata.parentNode) || document.body;
if (!initialized && isodata) {
hydrativeRender(jsx, parent);
} else {
render(jsx, parent);
}
initialized = true;
}
================================================
FILE: src/index.d.ts
================================================
export { default as prerender } from './prerender.js';
export * from './router.js';
export { default as lazy, ErrorBoundary } from './lazy.js';
export { default as hydrate } from './hydrate.js';
================================================
FILE: src/index.js
================================================
export { Router, LocationProvider, useLocation, Route, useRoute } from './router.js';
export { default as lazy, ErrorBoundary } from './lazy.js';
export { default as hydrate } from './hydrate.js';
export function prerender(vnode, options) {
return import('./prerender.js').then(m => m.default(vnode, options));
}
================================================
FILE: src/internal.d.ts
================================================
/// <reference types="navigation-api-types" />
import { Component } from 'preact';
export interface AugmentedComponent extends Component<any, any> {
__v: VNode;
__c: (error: Promise<void>, suspendingVNode: VNode) => void;
}
export interface VNode<P = any> extends preact.VNode<P> {
__c: AugmentedComponent;
__e?: Element | Text;
__u: number;
__h: boolean;
__v?: VNode<P>;
__k: Array<VNode<any>> | null;
}
export {}
================================================
FILE: src/lazy.d.ts
================================================
import { ComponentChildren, VNode } from 'preact';
export default function lazy<T>(load: () => Promise<{ default: T } | T>): T & {
preload: () => Promise<T>;
};
export function ErrorBoundary(props: { children?: ComponentChildren; onError?: (error: Error) => void }): VNode;
================================================
FILE: src/lazy.js
================================================
import { h, options } from 'preact';
import { useState, useRef } from 'preact/hooks';
const oldDiff = options.__b;
options.__b = (vnode) => {
if (vnode.type && vnode.type._forwarded && vnode.ref) {
vnode.props.ref = vnode.ref;
vnode.ref = null;
}
if (oldDiff) oldDiff(vnode);
};
export default function lazy(load) {
let p, c;
const loadModule = () =>
load().then(m => (c = (m && m.default) || m));
const LazyComponent = props => {
const [, update] = useState(0);
const r = useRef(c);
if (!p) p = loadModule();
if (c !== undefined) return h(c, props);
if (!r.current) r.current = p.then(() => update(1));
throw p;
};
LazyComponent.preload = () => {
if (!p) p = loadModule();
return p;
}
LazyComponent._forwarded = true;
return LazyComponent;
}
// See https://github.com/preactjs/preact/blob/88680e91ec0d5fc29d38554a3e122b10824636b6/compat/src/suspense.js#L5
const oldCatchError = options.__e;
options.__e = (err, newVNode, oldVNode) => {
if (err && err.then) {
let v = newVNode;
while ((v = v.__)) {
if (v.__c && v.__c.__c) {
if (newVNode.__e == null) {
newVNode.__c.__z = [oldVNode.__e];
newVNode.__e = oldVNode.__e; // ._dom
newVNode.__k = oldVNode.__k; // ._children
}
if (!newVNode.__k) newVNode.__k = [];
return v.__c.__c(err, newVNode);
}
}
}
if (oldCatchError) oldCatchError(err, newVNode, oldVNode);
};
export function ErrorBoundary(props) {
this.__c = childDidSuspend;
this.componentDidCatch = props.onError;
return props.children;
}
function childDidSuspend(err) {
err.then(() => this.forceUpdate());
}
================================================
FILE: src/prerender.d.ts
================================================
import { VNode } from 'preact';
export interface PrerenderOptions {
props?: Record<string, unknown>;
}
export interface PrerenderResult {
html: string;
links?: Set<string>
}
export default function prerender(
vnode: VNode,
options?: PrerenderOptions
): Promise<PrerenderResult>;
export function locationStub(path: string): void;
================================================
FILE: src/prerender.js
================================================
import { h, options, cloneElement } from 'preact';
import { renderToStringAsync } from 'preact-render-to-string';
let vnodeHook;
const old = options.vnode;
options.vnode = vnode => {
if (old) old(vnode);
if (vnodeHook) vnodeHook(vnode);
};
/**
* @param {ReturnType<h>} vnode The root JSX element to render (eg: `<App />`)
* @param {object} [options]
* @param {object} [options.props] Additional props to merge into the root JSX element
*/
export default async function prerender(vnode, options) {
options = options || {};
const props = options.props;
if (typeof vnode === 'function') {
vnode = h(vnode, props);
} else if (props) {
vnode = cloneElement(vnode, props);
}
let links = new Set();
vnodeHook = ({ type, props }) => {
if (type === 'a' && props && props.href && (!props.target || props.target === '_self')) {
links.add(props.href);
}
};
try {
let html = await renderToStringAsync(vnode);
html += `<script type="isodata"></script>`;
return { html, links };
} finally {
vnodeHook = null;
}
}
/**
* Update `location` to current URL so routers can use things like `location.pathname`
*
* @param {string} path - current URL path
*/
export function locationStub(path) {
globalThis.location = {};
const u = new URL(path, 'http://localhost');
for (const i in u) {
try {
globalThis.location[i] = /to[A-Z]/.test(i)
? u[i].bind(u)
: String(u[i]);
} catch {}
}
}
================================================
FILE: src/router-navigation-api.d.ts
================================================
import { AnyComponent, ComponentChildren, Context, VNode } from 'preact';
export const LocationProvider: {
(props: { scope?: string | RegExp; children?: ComponentChildren; }): VNode;
ctx: Context<LocationHook>;
};
type NestedArray<T> = Array<T | NestedArray<T>>;
interface KnownProps {
path: string;
query: Record<string, string>;
params: Record<string, string>;
default?: boolean;
rest?: string;
component?: AnyComponent;
}
interface ArbitraryProps {
[prop: string]: any;
}
type MatchProps = KnownProps & ArbitraryProps;
/**
* Check if a URL path matches against a URL path pattern.
*
* Warning: This is largely an internal API, it may change in the future
* @param url - URL path (e.g. /user/12345)
* @param route - URL pattern (e.g. /user/:id)
*/
export function exec(url: string, route: string, matches?: MatchProps): MatchProps
export function Router(props: {
onRouteChange?: (url: string) => void;
onLoadEnd?: (url: string) => void;
onLoadStart?: (url: string) => void;
children?: NestedArray<VNode>;
}): VNode;
interface LocationHook {
url: string;
path: string;
query: Record<string, string>;
}
export const useLocation: () => LocationHook;
interface RouteHook {
path: string;
query: Record<string, string>;
params: Record<string, string>;
}
export const useRoute: () => RouteHook;
type RoutableProps =
| { path: string; default?: false; }
| { path?: never; default: true; }
export type RouteProps<Props> = RoutableProps & { component: AnyComponent<Props> };
export type RoutePropsForPath<Path extends string> = Path extends '*'
? { params: {}; rest: string }
: Path extends `:${infer placeholder}?/${infer rest}`
? { [k in placeholder]?: string } & { params: RoutePropsForPath<rest>['params'] & { [k in placeholder]?: string } } & Omit<RoutePropsForPath<rest>, 'params'>
: Path extends `:${infer placeholder}/${infer rest}`
? { [k in placeholder]: string } & { params: RoutePropsForPath<rest>['params'] & { [k in placeholder]: string } } & Omit<RoutePropsForPath<rest>, 'params'>
: Path extends `:${infer placeholder}?`
? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } }
: Path extends `:${infer placeholder}*`
? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } }
: Path extends `:${infer placeholder}+`
? { [k in placeholder]: string } & { params: { [k in placeholder]: string } }
: Path extends `:${infer placeholder}`
? { [k in placeholder]: string } & { params: { [k in placeholder]: string } }
: Path extends (`/${infer rest}` | `${infer _}/${infer rest}`)
? RoutePropsForPath<rest>
: { params: {} };
export function Route<Props>(props: RouteProps<Props> & Partial<Props>): VNode;
declare module 'preact' {
// The code below automatically adds `path` and `default` as optional props for every component
// (effectively reserving those names, so no component should use those names in its own props).
// These declarations extend from `RouteableProps`, which is not allowed in modern TypeScript and
// causes a TS2312 error. However, the compiler does seems to honor the intent of this code, so
// to avoid an API regression, let's ignore the error rather than loosening the type validation.
namespace JSX {
/** @ts-ignore */
interface IntrinsicAttributes extends RoutableProps {}
}
/** @ts-ignore */
interface Attributes extends RoutableProps {}
}
================================================
FILE: src/router-navigation-api.js
================================================
import { h, createContext, cloneElement, toChildArray } from 'preact';
import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact/hooks';
/**
* @template T
* @typedef {import('preact').RefObject<T>} RefObject
* @typedef {import('./internal.d.ts').VNode} VNode
*/
/**
* @param {NavigateEvent} e
*/
function isSameWindow(e) {
const sourceElement = /** @type {HTMLAnchorElement | null} */ (e.sourceElement);
return (
!sourceElement ||
!sourceElement.target ||
/^(_self)?$/i.test(sourceElement.target)
);
}
/** @type {string | RegExp | undefined} */
let scope;
/**
* @param {URL} url
* @returns {boolean}
*/
function isInScope(url) {
return !scope || (typeof scope == 'string'
? url.pathname.startsWith(scope)
: scope.test(url.pathname)
);
}
/**
* @param {string} state
* @param {NavigateEvent} e
*/
function handleNav(state, e) {
const url = new URL(e.destination.url);
if (
!e.canIntercept ||
e.hashChange ||
e.downloadRequest !== null ||
!isSameWindow(e) ||
!isInScope(url)
) {
// This is set purely for our test suite so that we can check
// if the event was ignored in another `navigate` handler.
e['preact-iso-ignored'] = true;
return state;
}
e.intercept();
return url.href.replace(url.origin, '');
}
export const exec = (url, route, matches = {}) => {
url = url.split('/').filter(Boolean);
route = (route || '').split('/').filter(Boolean);
if (!matches.params) matches.params = {};
for (let i = 0, val, rest; i < Math.max(url.length, route.length); i++) {
let [, m, param, flag] = (route[i] || '').match(/^(:?)(.*?)([+*?]?)$/);
val = url[i];
// segment match:
if (!m && param == val) continue;
// /foo/* match
if (!m && val && flag == '*') {
matches.rest = '/' + url.slice(i).map(decodeURIComponent).join('/');
break;
}
// segment mismatch / missing required field:
if (!m || (!val && flag != '?' && flag != '*')) return;
rest = flag == '+' || flag == '*';
// rest (+/*) match:
if (rest) val = url.slice(i).join('/') || undefined;
// normal/optional field:
else if (val) val = decodeURIComponent(val);
matches.params[param] = val;
if (!(param in matches)) matches[param] = val;
if (rest) break;
}
return matches;
};
/**
* @param {Object} props
* @param {string | RegExp} [props.scope]
* @param {import('preact').ComponentChildren} [props.children]
*/
export function LocationProvider(props) {
const [url, route] = useReducer(handleNav, location.pathname + location.search);
if (props.scope) scope = props.scope;
const value = useMemo(() => {
const u = new URL(url, location.origin);
const path = u.pathname.replace(/\/+$/g, '') || '/';
return {
url,
path,
query: Object.fromEntries(u.searchParams),
};
}, [url]);
useLayoutEffect(() => {
navigation.addEventListener('navigate', route)
return () => {
navigation.removeEventListener('navigate', route)
};
}, []);
return h(LocationProvider.ctx.Provider, { value }, props.children);
}
const RESOLVED = Promise.resolve();
/** @this {import('./internal.d.ts').AugmentedComponent} */
export function Router(props) {
const [c, update] = useReducer(c => c + 1, 0);
const { url, query, path } = useLocation();
if (!url) {
throw new Error(`preact-iso's <Router> must be used within a <LocationProvider>, see: https://github.com/preactjs/preact-iso#locationprovider`);
}
const { rest = path, params = {} } = useContext(RouteContext);
const isLoading = useRef(false);
const prevRoute = useRef(path);
// Monotonic counter used to check if an un-suspending route is still the current route:
const count = useRef(0);
// The current route:
const cur = /** @type {RefObject<VNode<any>>} */ (useRef());
// Previous route (if current route is suspended):
const prev = /** @type {RefObject<VNode<any>>} */ (useRef());
// A not-yet-hydrated DOM root to remove once we commit:
const pendingBase = /** @type {RefObject<Element | Text>} */ (useRef());
// has this component ever successfully rendered without suspending:
const hasEverCommitted = useRef(false);
// was the most recent render successful (did not suspend):
const didSuspend = /** @type {RefObject<boolean>} */ (useRef());
didSuspend.current = false;
let pathRoute, defaultRoute, matchProps;
toChildArray(props.children).some((/** @type {VNode<any>} */ vnode) => {
const matches = exec(
rest,
vnode.props.path,
(matchProps = {
...vnode.props,
path: rest,
query,
params: Object.assign({}, params),
rest: ''
})
);
if (matches) return (pathRoute = cloneElement(vnode, matchProps));
if (vnode.props.default) defaultRoute = cloneElement(vnode, matchProps);
});
/** @type {VNode<any> | undefined} */
let incoming = pathRoute || defaultRoute;
const isHydratingSuspense = cur.current && cur.current.__u & MODE_HYDRATE && cur.current.__u & MODE_SUSPENDED;
const isHydratingBool = cur.current && cur.current.__h;
const routeChanged = useMemo(() => {
prev.current = cur.current;
cur.current = /** @type {VNode<any>} */ (h(RouteContext.Provider, { value: matchProps }, incoming));
// Only mark as an update if the route component changed.
const outgoing = prev.current && prev.current.props.children;
if (!outgoing || !incoming || incoming.type !== outgoing.type || incoming.props.component !== outgoing.props.component) {
// This hack prevents Preact from diffing when we swap `cur` to `prev`:
if (this.__v && this.__v.__k) this.__v.__k.reverse();
count.current++;
return true;
}
return false;
}, [url, JSON.stringify(matchProps)]);
if (isHydratingSuspense) {
cur.current.__u |= MODE_HYDRATE;
cur.current.__u |= MODE_SUSPENDED;
} else if (isHydratingBool) {
cur.current.__h = true;
}
// Reset previous children - if rendering succeeds synchronously, we shouldn't render the previous children.
const p = prev.current;
prev.current = null;
// This borrows the _childDidSuspend() solution from compat.
this.__c = (e, suspendedVNode) => {
// Mark the current render as having suspended:
didSuspend.current = true;
// The new route suspended, so keep the previous route around while it loads:
prev.current = p;
// Fire an event saying we're waiting for the route:
if (props.onLoadStart) props.onLoadStart(url);
isLoading.current = true;
// Re-render on unsuspend:
let c = count.current;
e.then(() => {
// Ignore this update if it isn't the most recently suspended update:
if (c !== count.current) return;
// Successful route transition: un-suspend after a tick and stop rendering the old route:
prev.current = null;
if (cur.current) {
if (suspendedVNode.__h) {
// _hydrating
cur.current.__h = suspendedVNode.__h;
}
if (suspendedVNode.__u & MODE_SUSPENDED) {
// _flags
cur.current.__u |= MODE_SUSPENDED;
}
if (suspendedVNode.__u & MODE_HYDRATE) {
cur.current.__u |= MODE_HYDRATE;
}
}
RESOLVED.then(update);
});
};
useLayoutEffect(() => {
const currentDom = this.__v && this.__v.__e;
// Ignore suspended renders (failed commits):
if (didSuspend.current) {
// If we've never committed, mark any hydration DOM for removal on the next commit:
if (!hasEverCommitted.current && !pendingBase.current) {
pendingBase.current = currentDom;
}
return;
}
// If this is the first ever successful commit and we didn't use the hydration DOM, remove it:
if (!hasEverCommitted.current && pendingBase.current) {
if (pendingBase.current !== currentDom) pendingBase.current.remove();
pendingBase.current = null;
}
// Mark the component has having committed:
hasEverCommitted.current = true;
// The route is loaded and rendered.
if (prevRoute.current !== path) {
if (props.onRouteChange) props.onRouteChange(url);
prevRoute.current = path;
}
if (props.onLoadEnd && isLoading.current) props.onLoadEnd(url);
isLoading.current = false;
}, [path, c]);
// Note: cur MUST render first in order to set didSuspend & prev.
return routeChanged
? [h(RenderRef, { r: cur }), h(RenderRef, { r: prev })]
: h(RenderRef, { r: cur });
}
const MODE_HYDRATE = 1 << 5;
const MODE_SUSPENDED = 1 << 7;
// Lazily render a ref's current value:
const RenderRef = ({ r }) => r.current;
Router.Provider = LocationProvider;
LocationProvider.ctx = createContext(
/** @type {import('./router-navigation-api.d.ts').LocationHook} */ ({})
);
const RouteContext = createContext(
/** @type {import('./router-navigation-api.d.ts').RouteHook & { rest: string }} */ ({})
);
export const Route = props => h(props.component, props);
export const useLocation = () => useContext(LocationProvider.ctx);
export const useRoute = () => useContext(RouteContext);
================================================
FILE: src/router.d.ts
================================================
import { AnyComponent, ComponentChildren, Context, VNode } from 'preact';
export const LocationProvider: {
(props: { scope?: string | RegExp; children?: ComponentChildren; }): VNode;
ctx: Context<LocationHook>;
};
type NestedArray<T> = Array<T | NestedArray<T>>;
interface KnownProps {
path: string;
query: Record<string, string>;
params: Record<string, string>;
default?: boolean;
rest?: string;
component?: AnyComponent;
}
interface ArbitraryProps {
[prop: string]: any;
}
type MatchProps = KnownProps & ArbitraryProps;
/**
* Check if a URL path matches against a URL path pattern.
*
* Warning: This is largely an internal API, it may change in the future
* @param url - URL path (e.g. /user/12345)
* @param route - URL pattern (e.g. /user/:id)
*/
export function exec(url: string, route: string, matches?: MatchProps): MatchProps
export function Router(props: {
onRouteChange?: (url: string) => void;
onLoadEnd?: (url: string) => void;
onLoadStart?: (url: string) => void;
children?: NestedArray<VNode>;
}): VNode;
interface LocationHook {
url: string;
path: string;
query: Record<string, string>;
route: (url: string, replace?: boolean) => void;
back: () => void;
forward: () => void;
}
export const useLocation: () => LocationHook;
interface RouteHook {
path: string;
query: Record<string, string>;
params: Record<string, string>;
}
export const useRoute: () => RouteHook;
type RoutableProps =
| { path: string; default?: false; }
| { path?: never; default: true; }
export type RouteProps<Props> = RoutableProps & { component: AnyComponent<Props> };
export type RoutePropsForPath<Path extends string> = Path extends '*'
? { params: {}; rest: string }
: Path extends `:${infer placeholder}?/${infer rest}`
? { [k in placeholder]?: string } & { params: RoutePropsForPath<rest>['params'] & { [k in placeholder]?: string } } & Omit<RoutePropsForPath<rest>, 'params'>
: Path extends `:${infer placeholder}/${infer rest}`
? { [k in placeholder]: string } & { params: RoutePropsForPath<rest>['params'] & { [k in placeholder]: string } } & Omit<RoutePropsForPath<rest>, 'params'>
: Path extends `:${infer placeholder}?`
? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } }
: Path extends `:${infer placeholder}*`
? { [k in placeholder]?: string } & { params: { [k in placeholder]?: string } }
: Path extends `:${infer placeholder}+`
? { [k in placeholder]: string } & { params: { [k in placeholder]: string } }
: Path extends `:${infer placeholder}`
? { [k in placeholder]: string } & { params: { [k in placeholder]: string } }
: Path extends (`/${infer rest}` | `${infer _}/${infer rest}`)
? RoutePropsForPath<rest>
: { params: {} };
export function Route<Props>(props: RouteProps<Props> & Partial<Props>): VNode;
declare module 'preact' {
// The code below automatically adds `path` and `default` as optional props for every component
// (effectively reserving those names, so no component should use those names in its own props).
// These declarations extend from `RouteableProps`, which is not allowed in modern TypeScript and
// causes a TS2312 error. However, the compiler does seems to honor the intent of this code, so
// to avoid an API regression, let's ignore the error rather than loosening the type validation.
namespace JSX {
/** @ts-ignore */
interface IntrinsicAttributes extends RoutableProps {}
}
/** @ts-ignore */
interface Attributes extends RoutableProps {}
}
================================================
FILE: src/router.js
================================================
import { h, createContext, cloneElement, toChildArray } from 'preact';
import { useContext, useMemo, useReducer, useLayoutEffect, useRef } from 'preact/hooks';
/**
* @template T
* @typedef {import('preact').RefObject<T>} RefObject
* @typedef {import('./internal.d.ts').VNode} VNode
*/
/** @type {boolean} */
let push;
/** @type {string | RegExp | undefined} */
let scope;
/**
* @param {string} href
* @returns {boolean}
*/
function isInScope(href) {
return !scope || (typeof scope == 'string'
? href.startsWith(scope)
: scope.test(href)
);
}
/**
* @param {string} state
* @param {MouseEvent | PopStateEvent | { url: string, replace?: boolean }} action
*/
function handleNav(state, action) {
let url = '';
push = undefined;
if (action && action.type === 'click') {
// ignore events the browser takes care of already:
if (action.ctrlKey || action.metaKey || action.altKey || action.shiftKey || action.button !== 0) {
return state;
}
const link = action.composedPath().find(el => el.nodeName == 'A' && el.href),
href = link && link.getAttribute('href');
if (
!link ||
link.origin != location.origin ||
/^#/.test(href) ||
!/^(_?self)?$/i.test(link.target) ||
!isInScope(href) ||
link.download
) {
return state;
}
push = true;
action.preventDefault();
url = link.href.replace(location.origin, '');
} else if (action && action.url) {
push = !action.replace;
url = action.url;
} else {
url = location.pathname + location.search;
}
if (push === true) history.pushState(null, '', url);
else if (push === false) history.replaceState(null, '', url);
return url;
};
export const exec = (url, route, matches = {}) => {
url = url.split('/').filter(Boolean);
route = (route || '').split('/').filter(Boolean);
if (!matches.params) matches.params = {};
for (let i = 0, val, rest; i < Math.max(url.length, route.length); i++) {
let [, m, param, flag] = (route[i] || '').match(/^(:?)(.*?)([+*?]?)$/);
val = url[i];
// segment match:
if (!m && param == val) continue;
// /foo/* match
if (!m && val && flag == '*') {
matches.rest = '/' + url.slice(i).join('/');
break;
}
// segment mismatch / missing required field:
if (!m || (!val && flag != '?' && flag != '*')) return;
rest = flag == '+' || flag == '*';
// rest (+/*) match:
if (rest) val = url.slice(i).map(decodeURIComponent).join('/') || undefined;
// normal/optional field:
else if (val) val = decodeURIComponent(val);
matches.params[param] = val;
if (!(param in matches)) matches[param] = val;
if (rest) break;
}
return matches;
};
/**
* @param {Object} props
* @param {string | RegExp} [props.scope]
* @param {import('preact').ComponentChildren} [props.children]
*/
export function LocationProvider(props) {
// @ts-expect-error - props.url is not implemented correctly & will be removed in the future
const [url, route] = useReducer(handleNav, props.url || location.pathname + location.search);
if (props.scope) scope = props.scope;
const wasPush = push === true;
const value = useMemo(() => {
const u = new URL(url, location.origin);
const path = u.pathname.replace(/\/+$/g, '') || '/';
// @ts-ignore-next
return {
url,
path,
query: Object.fromEntries(u.searchParams),
route: (url, replace) => route({ url, replace }),
back: () => {
history.back();
},
forward: () => {
history.forward();
},
wasPush
};
}, [url]);
useLayoutEffect(() => {
addEventListener('click', route);
addEventListener('popstate', route);
return () => {
removeEventListener('click', route);
removeEventListener('popstate', route);
};
}, []);
// @ts-ignore
return h(LocationProvider.ctx.Provider, { value }, props.children);
}
const RESOLVED = Promise.resolve();
/** @this {import('./internal.d.ts').AugmentedComponent} */
export function Router(props) {
const [c, update] = useReducer(c => c + 1, 0);
const { url, query, wasPush, path } = useLocation();
if (!url) {
throw new Error(`preact-iso's <Router> must be used within a <LocationProvider>, see: https://github.com/preactjs/preact-iso#locationprovider`);
}
const { rest = path, params = {} } = useContext(RouteContext);
const isLoading = useRef(false);
const prevRoute = useRef(path);
// Monotonic counter used to check if an un-suspending route is still the current route:
const count = useRef(0);
// The current route:
const cur = /** @type {RefObject<VNode<any>>} */ (useRef());
// Previous route (if current route is suspended):
const prev = /** @type {RefObject<VNode<any>>} */ (useRef());
// A not-yet-hydrated DOM root to remove once we commit:
const pendingBase = /** @type {RefObject<Element | Text>} */ (useRef());
// has this component ever successfully rendered without suspending:
const hasEverCommitted = useRef(false);
// was the most recent render successful (did not suspend):
const didSuspend = /** @type {RefObject<boolean>} */ (useRef());
didSuspend.current = false;
let pathRoute, defaultRoute, matchProps;
toChildArray(props.children).some((/** @type {VNode<any>} */ vnode) => {
const matches = exec(
rest,
vnode.props.path,
(matchProps = {
...vnode.props,
path: rest,
query,
params: Object.assign({}, params),
rest: ''
})
);
if (matches) return (pathRoute = cloneElement(vnode, matchProps));
if (vnode.props.default) defaultRoute = cloneElement(vnode, matchProps);
});
/** @type {VNode<any> | undefined} */
let incoming = pathRoute || defaultRoute;
const isHydratingSuspense = cur.current && cur.current.__u & MODE_HYDRATE && cur.current.__u & MODE_SUSPENDED;
const isHydratingBool = cur.current && cur.current.__h;
const routeChanged = useMemo(() => {
prev.current = cur.current;
cur.current = /** @type {VNode<any>} */ (h(RouteContext.Provider, { value: matchProps }, incoming));
// Only mark as an update if the route component changed.
const outgoing = prev.current && prev.current.props.children;
if (!outgoing || !incoming || incoming.type !== outgoing.type || incoming.props.component !== outgoing.props.component) {
// This hack prevents Preact from diffing when we swap `cur` to `prev`:
if (this.__v && this.__v.__k) this.__v.__k.reverse();
count.current++;
return true;
}
return false;
}, [url, JSON.stringify(matchProps)]);
if (isHydratingSuspense) {
cur.current.__u |= MODE_HYDRATE;
cur.current.__u |= MODE_SUSPENDED;
} else if (isHydratingBool) {
cur.current.__h = true;
}
// Reset previous children - if rendering succeeds synchronously, we shouldn't render the previous children.
const p = prev.current;
prev.current = null;
// This borrows the _childDidSuspend() solution from compat.
this.__c = (e, suspendedVNode) => {
// Mark the current render as having suspended:
didSuspend.current = true;
// The new route suspended, so keep the previous route around while it loads:
prev.current = p;
// Fire an event saying we're waiting for the route:
if (props.onLoadStart) props.onLoadStart(url);
isLoading.current = true;
// Re-render on unsuspend:
let c = count.current;
e.then(() => {
// Ignore this update if it isn't the most recently suspended update:
if (c !== count.current) return;
// Successful route transition: un-suspend after a tick and stop rendering the old route:
prev.current = null;
if (cur.current) {
if (suspendedVNode.__h) {
// _hydrating
cur.current.__h = suspendedVNode.__h;
}
if (suspendedVNode.__u & MODE_SUSPENDED) {
// _flags
cur.current.__u |= MODE_SUSPENDED;
}
if (suspendedVNode.__u & MODE_HYDRATE) {
cur.current.__u |= MODE_HYDRATE;
}
}
RESOLVED.then(update);
});
};
useLayoutEffect(() => {
const currentDom = this.__v && this.__v.__e;
// Ignore suspended renders (failed commits):
if (didSuspend.current) {
// If we've never committed, mark any hydration DOM for removal on the next commit:
if (!hasEverCommitted.current && !pendingBase.current) {
pendingBase.current = currentDom;
}
return;
}
// If this is the first ever successful commit and we didn't use the hydration DOM, remove it:
if (!hasEverCommitted.current && pendingBase.current) {
if (pendingBase.current !== currentDom) pendingBase.current.remove();
pendingBase.current = null;
}
// Mark the component has having committed:
hasEverCommitted.current = true;
// The route is loaded and rendered.
if (prevRoute.current !== path) {
if (wasPush) scrollTo(0, 0);
if (props.onRouteChange) props.onRouteChange(url);
prevRoute.current = path;
}
if (props.onLoadEnd && isLoading.current) props.onLoadEnd(url);
isLoading.current = false;
}, [path, wasPush, c]);
// Note: cur MUST render first in order to set didSuspend & prev.
return routeChanged
? [h(RenderRef, { r: cur }), h(RenderRef, { r: prev })]
: h(RenderRef, { r: cur });
}
const MODE_HYDRATE = 1 << 5;
const MODE_SUSPENDED = 1 << 7;
// Lazily render a ref's current value:
const RenderRef = ({ r }) => r.current;
Router.Provider = LocationProvider;
LocationProvider.ctx = createContext(
/** @type {import('./router.d.ts').LocationHook & { wasPush: boolean }} */ ({})
);
const RouteContext = createContext(
/** @type {import('./router.d.ts').RouteHook & { rest: string }} */ ({})
);
export const Route = props => h(props.component, props);
export const useLocation = () => useContext(LocationProvider.ctx);
export const useRoute = () => useContext(RouteContext);
================================================
FILE: test/lazy.test.js
================================================
import { h, render } from 'preact';
import * as chai from 'chai';
import * as sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { LocationProvider, Router } from '../src/router.js';
import lazy, { ErrorBoundary } from '../src/lazy.js';
import './setup.js';
const expect = chai.expect;
chai.use(sinonChai);
describe('lazy', () => {
let scratch;
beforeEach(() => {
if (scratch) {
render(null, scratch);
scratch.remove();
}
scratch = document.createElement('scratch');
document.body.appendChild(scratch);
history.replaceState(null, null, '/');
});
it('should support preloading lazy imports', async () => {
const A = () => <h1>A</h1>;
const loadB = sinon.fake(() => Promise.resolve(() => <h1>B</h1>));
const B = lazy(loadB);
render(
<LocationProvider>
<Router>
<A path="/" />
<B path="/b" />
</Router>
</LocationProvider>,
scratch
);
expect(loadB).not.to.have.been.called;
await B.preload();
expect(loadB).to.have.been.calledOnce;
});
it('should forward refs', async () => {
const A = (props) => <h1 ref={props.ref}>A</h1>;
const LazyA = lazy(() => Promise.resolve(A));
const ref = {};
render(
<ErrorBoundary>
<LazyA ref={ref} />
</ErrorBoundary>,
scratch
);
await new Promise(r => setTimeout(r, 1))
if (ref.current.constructor === A) {
// v10
expect(ref.current.constructor).to.equal(A);
} else {
// v11+
expect(ref.current).to.equal(scratch.firstChild);
}
});
});
================================================
FILE: test/node/location-stub.test.js
================================================
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { locationStub } from '../../src/prerender.js';
test.before.each(() => {
if (globalThis.location) {
delete globalThis.location;
}
});
test('Contains all Location instance properties', () => {
locationStub('/foo/bar?baz=qux#quux');
[
// 'ancestorOrigins', // Not supported by FireFox and sees little use, but we could add an empty val if it's needed
'hash',
'host',
'hostname',
'href',
'origin',
'pathname',
'port',
'protocol',
'search',
].forEach(key => {
assert.ok(Object.hasOwn(globalThis.location, key), `missing: ${key}`);
});
});
// Do we need to support `assign`, `reload`, and/or `replace`?
test('Support bound methods', () => {
locationStub('/foo/bar?baz=qux#quux');
assert.equal(globalThis.location.toString(), 'http://localhost/foo/bar?baz=qux#quux')
});
test.run();
================================================
FILE: test/node/pattern-match.types.ts
================================================
// Test this file by running:
// npx tsc --noEmit test/node/pattern-match.types.ts
import type { RoutePropsForPath } from '../../src/router.js';
// Test utils
type isEqualsType<T, U> = T extends U ? U extends T ? true : false : false;
type isWeakEqualsType<T, U> = T extends U ? true : false;
// Type tests based on router-match.test.js cases
// Base route test
const test1: isEqualsType<
RoutePropsForPath<'/'> ,
{ params: {} }
> = true;
const test1_1: isEqualsType<
RoutePropsForPath<'/'> ,
{ arbitrary: {} }
> = false;
// Param route test
const test2: isEqualsType<
RoutePropsForPath<'/user/:id'> ,
{ params: { id: string }, id: string }
> = true;
const test2_weak: isWeakEqualsType<
RoutePropsForPath<'/user/:id'> ,
{ params: { id: string } }
> = true;
// Param rest segment test
const test3: isEqualsType<
RoutePropsForPath<'/user/*'> ,
{ params: {}, rest: string }
> = true;
const test3_1: isEqualsType<
RoutePropsForPath<'/*'> ,
{ params: {}, rest: string }
> = true;
const test3_2: isEqualsType<
RoutePropsForPath<'*'> ,
{ params: {}, rest: string }
> = true;
// Param route with rest segment test
const test4: isEqualsType<
RoutePropsForPath<'/user/:id/*'> ,
{ params: { id: string }, id: string, rest: string }
> = true;
// Optional param route test
const test5: isEqualsType<
RoutePropsForPath<'/user/:id?'> ,
{ params: { id?: string }, id?: string }
> = true;
// Optional rest param route "/:x*" test
const test6: isEqualsType<
RoutePropsForPath<'/user/:id*'> ,
{ params: { id?: string }, id?: string }
> = true;
// rest param should not be present
const test6_error: isEqualsType<
RoutePropsForPath<'/user/:id*'> ,
{ params: { id: string }, rest: string }
> = false;
// Rest param route "/:x+" test
const test7: isEqualsType<
RoutePropsForPath<'/user/:id+'> ,
{ params: { id: string }, id: string }
> = true;
// rest param should not be present
const test7_error: isEqualsType<
RoutePropsForPath<'/user/:id+'>,
{ params: { id: string }, id: string, rest: string }
> = false;
// Handles leading/trailing slashes test
const test8: isEqualsType<
RoutePropsForPath<'/about-late/:seg1/:seg2/'> ,
{ params: { seg1: string; seg2: string }, seg1: string, seg2: string }
> = true;
// Multiple params test (from overwrite properties test)
const test9: isEqualsType<
RoutePropsForPath<'/:path/:query'> ,
{ params: { path: string; query: string }, path: string, query: string }
> = true;
// Empty route test
const test10: isEqualsType<
RoutePropsForPath<''> ,
{ params: {} }
> = true;
================================================
FILE: test/node/prerender.test.js
================================================
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { html } from 'htm/preact';
import { default as prerender } from '../../src/prerender.js';
test('extracts links', async () => {
const App = () => html`
<div>
<a href="/foo">foo</a>
<a href="/bar" target="_blank">bar</a>
<a href="/baz" target="_self">baz</a>
</div>
`;
const { links } = await prerender(html`<${App} />`);
assert.equal(links.size, 2, `incorrect number of links found: ${links.size}`);
assert.ok(links.has('/foo'), `missing: /foo`);
assert.ok(links.has('/baz'), `missing: /baz`);
});
test('appends iso data script', async () => {
const { html: h } = await prerender(html`<div />`);
// Empty for now, but used for hydration vs render detection
assert.match(h, /<script type="isodata"><\/script>/, 'missing iso data script tag');
});
test.run();
================================================
FILE: test/node/router-match.test.js
================================================
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { exec } from '../../src/router.js';
function execPath(path, pattern, opts) {
return exec(path, pattern, { path, query: {}, params: {}, ...(opts || {}) });
}
test('Base route', () => {
const accurateResult = execPath('/', '/');
assert.equal(accurateResult, { path: '/', params: {}, query: {} });
const inaccurateResult = execPath('/user/1', '/');
assert.equal(inaccurateResult, undefined);
});
test('Param route', () => {
const accurateResult = execPath('/user/2', '/user/:id');
assert.equal(accurateResult, { path: '/user/2', params: { id: '2' }, id: '2', query: {} });
const inaccurateResult = execPath('/', '/user/:id');
assert.equal(inaccurateResult, undefined);
});
test('Param rest segment', () => {
const accurateResult = execPath('/user/foo', '/user/*');
assert.equal(accurateResult, { path: '/user/foo', params: {}, query: {}, rest: '/foo' });
const accurateResult2 = execPath('/user/foo/bar/baz', '/user/*');
assert.equal(accurateResult2, { path: '/user/foo/bar/baz', params: {}, query: {}, rest: '/foo/bar/baz' });
const inaccurateResult = execPath('/user', '/user/*');
assert.equal(inaccurateResult, undefined);
});
test('Param route with rest segment', () => {
const accurateResult = execPath('/user/2/foo', '/user/:id/*');
assert.equal(accurateResult, { path: '/user/2/foo', params: { id: '2' }, id: '2', query: {}, rest: '/foo' });
const accurateResult2 = execPath('/user/2/foo/bar/bob', '/user/:id/*');
assert.equal(accurateResult2, {
path: '/user/2/foo/bar/bob',
params: { id: '2' },
id: '2',
query: {},
rest: '/foo/bar/bob'
});
const inaccurateResult = execPath('/', '/user/:id/*');
assert.equal(inaccurateResult, undefined);
});
test('Optional param route', () => {
const accurateResult = execPath('/user', '/user/:id?');
assert.equal(accurateResult, { path: '/user', params: { id: undefined }, id: undefined, query: {} });
const inaccurateResult = execPath('/', '/user/:id?');
assert.equal(inaccurateResult, undefined);
});
test('Optional rest param route "/:x*"', () => {
const matchedResult = execPath('/user/foo', '/user/:id*');
assert.equal(matchedResult, { path: '/user/foo', params: { id: 'foo' }, id: 'foo', query: {} });
const matchedResultWithSlash = execPath('/user/foo/bar', '/user/:id*');
assert.equal(matchedResultWithSlash, {
path: '/user/foo/bar',
params: { id: 'foo/bar' },
id: 'foo/bar',
query: {}
});
const emptyResult = execPath('/user', '/user/:id*');
assert.equal(emptyResult, {
path: '/user',
params: { id: undefined },
id: undefined,
query: {}
});
const inaccurateResult = execPath('/', '/user/:id*');
assert.equal(inaccurateResult, undefined);
});
test('Rest param route "/:x+"', () => {
const matchedResult = execPath('/user/foo', '/user/:id+');
assert.equal(matchedResult, { path: '/user/foo', params: { id: 'foo' }, id: 'foo', query: {} });
const matchedResultWithSlash = execPath('/user/foo/bar', '/user/:id+');
assert.equal(matchedResultWithSlash, {
path: '/user/foo/bar',
params: { id: 'foo/bar' },
id: 'foo/bar',
query: {}
});
const emptyResult = execPath('/user', '/user/:id+');
assert.equal(emptyResult, undefined);
const mismatchedResult = execPath('/', '/user/:id+');
assert.equal(mismatchedResult, undefined);
});
test('Handles leading/trailing slashes', () => {
const result = execPath('/about-late/_SEGMENT1_/_SEGMENT2_/', '/about-late/:seg1/:seg2/');
assert.equal(result, {
path: '/about-late/_SEGMENT1_/_SEGMENT2_/',
params: {
seg1: '_SEGMENT1_',
seg2: '_SEGMENT2_'
},
seg1: '_SEGMENT1_',
seg2: '_SEGMENT2_',
query: {}
});
});
test('Percent-encoded characters in rest are not decoded', () => {
const result = execPath('/nested/child/%25', '/nested/*');
assert.equal(result, { path: '/nested/child/%25', params: {}, query: {}, rest: '/child/%25' });
});
test('should not overwrite existing properties', () => {
const result = execPath('/foo/bar', '/:path/:query', { path: '/custom-path' });
assert.equal(result, {
params: { path: 'foo', query: 'bar' },
path: '/custom-path',
query: {}
});
});
test.run();
================================================
FILE: test/router-navigation-api.test.js
================================================
import { h, Fragment, render, Component, hydrate, options } from 'preact';
import { useState } from 'preact/hooks';
import * as chai from 'chai';
import * as sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { LocationProvider, Router, useLocation, Route, useRoute } from '../src/router-navigation-api.js';
import lazy, { ErrorBoundary } from '../src/lazy.js';
import './setup.js';
const expect = chai.expect;
chai.use(sinonChai);
/**
* Usage:
* - `await sleep(1)` for nav + loc/pushState/sync component check
* - `await sleep(10)` for nav + async component check
*/
const sleep = ms => new Promise(r => setTimeout(r, ms));
// delayed lazy()
const groggy = (component, ms) => lazy(() => sleep(ms).then(() => component));
describe('Router', () => {
let scratch, loc;
const ShallowLocation = () => {
loc = useLocation();
return null;
}
beforeEach(() => {
if (scratch) {
render(null, scratch);
scratch.remove();
}
loc = undefined;
scratch = document.createElement('scratch');
document.body.appendChild(scratch);
history.replaceState(null, null, '/');
});
it('should throw a clear error if the LocationProvider is missing', () => {
const Home = () => <h1>Home</h1>;
try {
render(
<Router>
<Home path="/" test="2" />
</Router>,
scratch
);
expect.fail('should have thrown');
} catch (e) {
expect(e.message).to.include('must be used within a <LocationProvider>');
}
});
it('should strip trailing slashes from path', async () => {
render(
<LocationProvider>
<ShallowLocation />
</LocationProvider>,
scratch
);
navigation.navigate('/a/');
await sleep(1);
expect(loc).to.deep.include({
url: '/a/',
path: '/a',
query: {},
});
});
it('should support class components using LocationProvider.ctx', () => {
class Foo extends Component {
static contextType = LocationProvider.ctx;
render() {
loc = this.context;
return <h1>{loc.url}</h1>;
}
}
render(
<LocationProvider>
<Foo />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('innerHTML', '<h1>/</h1>');
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
});
it('should allow passing props to a route', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
render(
<LocationProvider>
<Router>
<Home path="/" test="2" />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
});
it('should allow updating props in a route', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
/** @type {(string) => void} */
let set;
const App = () => {
const [test, setTest] = useState('2');
set = setTest;
return (
<LocationProvider>
<Router>
<Home path="/" test={test} />
</Router>
<ShallowLocation />
</LocationProvider>
);
}
render(<App />, scratch);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
set('3')
await sleep(1);
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '3' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
expect(scratch).to.have.property('textContent', 'Home');
});
it('should switch between synchronous routes', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
const Profiles = sinon.fake(() => <h1>Profiles</h1>);
const Profile = sinon.fake(({ params }) => <h1>Profile: {params.id}</h1>);
const Fallback = sinon.fake(() => <h1>Fallback</h1>);
const stack = [];
render(
<LocationProvider>
<Router onRouteChange={url => stack.push(url)}>
<Home path="/" />
<Profiles path="/profiles" />
<Profile path="/profiles/:id" />
<Fallback default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(Profiles).not.to.have.been.called;
expect(Profile).not.to.have.been.called;
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
Home.resetHistory();
navigation.navigate('/profiles');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Profiles');
expect(Home).not.to.have.been.called;
expect(Profiles).to.have.been.calledWith({ path: '/profiles', query: {}, params: {}, rest: '' });
expect(Profile).not.to.have.been.called;
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/profiles',
path: '/profiles',
query: {}
});
Profiles.resetHistory();
navigation.navigate('/profiles/bob');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Profile: bob');
expect(Home).not.to.have.been.called;
expect(Profiles).not.to.have.been.called;
expect(Profile).to.have.been.calledWith(
{ path: '/profiles/bob', query: {}, params: { id: 'bob' }, id: 'bob', rest: '' },
);
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/profiles/bob',
path: '/profiles/bob',
query: {}
});
Profile.resetHistory();
navigation.navigate('/other?a=b&c=d');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Fallback');
expect(Home).not.to.have.been.called;
expect(Profiles).not.to.have.been.called;
expect(Profile).not.to.have.been.called;
expect(Fallback).to.have.been.calledWith(
{ default: true, path: '/other', query: { a: 'b', c: 'd' }, params: {}, rest: '' },
);
expect(loc).to.deep.include({
url: '/other?a=b&c=d',
path: '/other',
query: { a: 'b', c: 'd' }
});
expect(stack).to.eql(['/profiles', '/profiles/bob', '/other?a=b&c=d']);
});
it('should wait for asynchronous routes', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const B = sinon.fake(groggy(() => route('B'), 1));
const C = sinon.fake(groggy(() => <h1>C</h1>, 1));
render(
<ErrorBoundary>
<LocationProvider>
<Router>
<A path="/" />
<B path="/b" />
<C path="/c" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
A.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
A.resetHistory();
navigation.navigate('/b');
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).not.to.have.been.called;
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
// We should never re-invoke <A /> while loading <B /> (that would be a remount of the old route):
expect(A).not.to.have.been.called;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
B.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(B).to.have.been.calledOnce;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
B.resetHistory();
navigation.navigate('/c');
navigation.navigate('/c?1');
navigation.navigate('/c');
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(B).not.to.have.been.called;
await sleep(1);
navigation.navigate('/c');
navigation.navigate('/c?2');
navigation.navigate('/c');
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
// We should never re-invoke <B /> while loading <C /> (that would be a remount of the old route):
expect(B).not.to.have.been.called;
expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' });
C.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>C</h1>');
expect(C).to.have.been.calledOnce;
expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' });
// "instant" routing to already-loaded routes
C.resetHistory();
B.resetHistory();
navigation.navigate('/b');
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(C).not.to.have.been.called;
expect(B).to.have.been.calledOnce;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
A.resetHistory();
B.resetHistory();
navigation.navigate('/');
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(B).not.to.have.been.called;
expect(A).to.have.been.calledOnce;
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
});
it('rerenders same-component routes rather than swap', async () => {
const A = sinon.fake(() => <h1>a</h1>);
const B = sinon.fake(groggy(({ sub }) => <h1>b/{sub}</h1>, 1));
// Counts the wrappers around route components to determine what the Router is returning
// Count will be 2 for switching route components, and 2 more if the new route is lazily loaded
// A same-route navigation adds 1
let renderRefCount = 0;
const old = options.vnode;
options.vnode = (vnode) => {
if (typeof vnode.type === 'function' && vnode.props.r !== undefined) {
renderRefCount += 1;
}
if (old) old(vnode);
}
render(
<ErrorBoundary>
<LocationProvider>
<Router>
<A path="/" />
<B path="/b/:sub" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '<h1>a</h1>');
expect(renderRefCount).to.equal(2);
renderRefCount = 0;
navigation.navigate('/b/a');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>b/a</h1>');
expect(renderRefCount).to.equal(4);
renderRefCount = 0;
navigation.navigate('/b/b');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>b/b</h1>');
expect(renderRefCount).to.equal(1);
renderRefCount = 0;
navigation.navigate('/');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>a</h1>');
expect(renderRefCount).to.equal(2);
options.vnode = old;
});
it('should support onLoadStart/onLoadEnd/onRouteChange w/out navigation', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const loadStart = sinon.fake();
const loadEnd = sinon.fake();
const routeChange = sinon.fake();
render(
<ErrorBoundary>
<LocationProvider>
<Router
onLoadStart={loadStart}
onLoadEnd={loadEnd}
onRouteChange={routeChange}
>
<A path="/" />
</Router>
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(loadStart).to.have.been.calledWith('/');
expect(loadEnd).not.to.have.been.called;
expect(routeChange).not.to.have.been.called;
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(loadStart).not.to.have.been.called;
expect(loadEnd).to.have.been.calledWith('/');
expect(routeChange).not.to.have.been.called;
});
it('should support onLoadStart/onLoadEnd/onRouteChange w/ navigation', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(() => route('A'));
const B = sinon.fake(groggy(() => route('B'), 1));
const loadStart = sinon.fake();
const loadEnd = sinon.fake();
const routeChange = sinon.fake();
render(
<ErrorBoundary>
<LocationProvider>
<Router
onLoadStart={loadStart}
onLoadEnd={loadEnd}
onRouteChange={routeChange}
>
<A path="/" />
<B path="/b" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
navigation.navigate('/b');
await sleep(1);
expect(loadStart).to.have.been.calledWith('/b');
expect(loadEnd).not.to.have.been.called;
expect(routeChange).not.to.have.been.called;
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(loadStart).not.to.have.been.called;
expect(loadEnd).to.have.been.calledWith('/b');
expect(routeChange).to.have.been.calledWith('/b');
});
it('should only call onLoadEnd once upon promise flush', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const loadEnd = sinon.fake();
/** @type {(string) => void} */
let set;
const App = () => {
set = useState('1')[1];
return (
<ErrorBoundary>
<LocationProvider>
<Router onLoadEnd={loadEnd}>
<A path="/" />
</Router>
</LocationProvider>
</ErrorBoundary>
);
}
render(<App />, scratch);
await sleep(10);
expect(loadEnd).to.have.been.calledWith('/');
loadEnd.resetHistory();
set('2');
await sleep(1);
expect(loadEnd).not.to.have.been.called;
});
describe.skip('intercepted VS external links', () => {
const shouldIntercept = [null, '', '_self', 'self', '_SELF'];
const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK'];
const clickHandler = sinon.fake(e => e.preventDefault());
const Route = sinon.fake(
() => <div>
{[...shouldIntercept, ...shouldNavigate].map((target, i) => {
const url = '/' + i + '/' + target;
if (target === null) return <a href={url}>target = {target + ''}</a>;
return <a href={url} target={target}>target = {target}</a>;
})}
</div>
);
let pushState;
before(() => {
pushState = sinon.spy(history, 'pushState');
addEventListener('click', clickHandler);
});
after(() => {
pushState.restore();
removeEventListener('click', clickHandler);
});
beforeEach(async () => {
render(
<LocationProvider>
<Router>
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
Route.resetHistory();
clickHandler.resetHistory();
pushState.resetHistory();
});
const getName = target => (target == null ? 'no target attribute' : `target="${target}"`);
// these should all be intercepted by the router.
for (const target of shouldIntercept) {
it(`should intercept clicks on links with ${getName(target)}`, async () => {
const sel = target == null ? `a:not([target])` : `a[target="${target}"]`;
const el = scratch.querySelector(sel);
if (!el) throw Error(`Unable to find link: ${sel}`);
const url = el.getAttribute('href');
el.click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(Route).to.have.been.calledOnce;
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
});
}
// these should all navigate.
for (const target of shouldNavigate) {
it(`should allow default browser navigation for links with ${getName(target)}`, async () => {
const sel = target == null ? `a:not([target])` : `a[target="${target}"]`;
const el = scratch.querySelector(sel);
if (!el) throw Error(`Unable to find link: ${sel}`);
el.click();
await sleep(1);
expect(Route).not.to.have.been.called;
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
});
}
});
describe.skip('intercepted VS external links with `scope`', () => {
const shouldIntercept = ['/app', '/app/deeper'];
const shouldNavigate = ['/site', '/site/deeper'];
const clickHandler = sinon.fake(e => e.preventDefault());
const Links = () => (
<>
<a href="/app">Internal Link</a>
<a href="/app/deeper">Internal Deeper Link</a>
<a href="/site">External Link</a>
<a href="/site/deeper">External Deeper Link</a>
</>
);
let pushState;
before(() => {
pushState = sinon.spy(history, 'pushState');
addEventListener('click', clickHandler);
});
after(() => {
pushState.restore();
removeEventListener('click', clickHandler);
});
beforeEach(async () => {
clickHandler.resetHistory();
pushState.resetHistory();
});
it('should intercept clicks on links matching the `scope` props (string)', async () => {
render(
<LocationProvider scope="/app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldIntercept) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should allow default browser navigation for links not matching the `scope` props (string)', async () => {
render(
<LocationProvider scope="app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldNavigate) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should intercept clicks on links matching the `scope` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldIntercept) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should allow default browser navigation for links not matching the `scope` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldNavigate) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
});
it('should ignore clicks on document fragment links', async () => {
const Route = sinon.fake(
() => <div>
<a href="#foo">just #foo</a>
<a href="/other#bar">other #bar</a>
</div>
);
render(
<LocationProvider>
<Router>
<Route path="/" />
<Route path="/other" />
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scratch.querySelector('a[href="#foo"]').click();
await sleep(1);
// NOTE: we don't (currently) propagate in-page anchor navigations into context, to avoid useless renders.
expect(loc).to.deep.include({ url: '/' });
expect(Route).not.to.have.been.called;
expect(location.hash).to.equal('#foo');
scratch.querySelector('a[href="/other#bar"]').click();
await sleep(1);
expect(Route).to.have.been.calledOnce;
expect(loc).to.deep.include({ url: '/other#bar', path: '/other' });
expect(location.hash).to.equal('#bar');
});
it('should ignore clicks on download links', async () => {
const downloadHref = URL.createObjectURL(new Blob(['Hello World!'], { type: 'text/plain' }));
render(
<LocationProvider>
<Router>
<a href={downloadHref} download="hello-world.txt">Download Me</a>
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
scratch.querySelector('a[download]').click();
await sleep(1);
// If the router attempted to navigate, the page would throw a SecurityError
// and the test would fail.
expect(true).to.equal(true);
});
it('should normalize children', async () => {
const Route = sinon.fake(() => <a href="/foo#foo">foo</a>);
const routes = ['/foo', '/bar'];
render(
<LocationProvider>
<Router>
{routes.map(route => <Route path={route} />)}
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scratch.querySelector('a[href="/foo#foo"]').click();
await sleep(10);
expect(Route).to.have.been.calledOnce;
expect(loc).to.deep.include({ url: '/foo#foo', path: '/foo' });
});
it('should match nested routes', async () => {
let route;
const Inner = () => (
<Router>
<Route
path="/bob"
component={() => {
route = useRoute();
return null;
}}
/>
</Router>
);
render(
<LocationProvider>
<Router>
<Route path="/foo/:id/*" component={Inner} />
</Router>
<a href="/foo/bar/bob"></a>
</LocationProvider>,
scratch
);
scratch.querySelector('a[href="/foo/bar/bob"]').click();
await sleep(1);
expect(route).to.deep.include({ path: '/bob', params: { id: 'bar' } });
});
it('should append params in nested routes', async () => {
let params;
const Inner = () => (
<Router>
<Route
path="/bob"
component={() => {
params = useRoute().params;
return null;
}}
/>
</Router>
);
render(
<LocationProvider>
<Router>
<Route path="/foo/:id/*" component={Inner} />
</Router>
<a href="/foo/bar/bob"></a>
</LocationProvider>,
scratch
);
scratch.querySelector('a[href="/foo/bar/bob"]').click();
await sleep(1);
expect(params).to.deep.include({ id: 'bar' });
});
it('should replace the current URL', async () => {
render(
<LocationProvider>
<Router>
<Route path="/" component={() => null} />
<Route path="/foo" component={() => null} />
<Route path="/bar" component={() => null} />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
navigation.navigate('/foo');
navigation.navigate('/bar', { history: 'replace' });
const entries = navigation.entries();
// Top of the stack
const last = new URL(entries[entries.length - 1].url);
expect(last.pathname).to.equal('/bar');
// Entry before
const secondLast = new URL(entries[entries.length - 2].url);
expect(secondLast.pathname).to.equal('/');
});
it('should support using `Router` as an implicit suspense boundary', async () => {
let data;
function useSuspense() {
const [_, update] = useState();
if (!data) {
data = new Promise(r => setTimeout(r, 5, 'data'));
data.then(
(res) => update((data.res = res)),
(err) => update((data.err = err))
);
}
if (data.res) return data.res;
if (data.err) throw data.err;
throw data;
}
render(
<LocationProvider>
<Router>
<Route
path="/"
component={() => {
const result = useSuspense();
return <h1>{result}</h1>;
}}
/>
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('textContent', '');
await sleep(10);
expect(scratch).to.have.property('textContent', 'data');
});
it('should intercept clicks on links inside open shadow DOM', async () => {
const shadowlink = document.createElement('a');
shadowlink.href = '/shadow';
shadowlink.textContent = 'Shadow Link';
const attachShadow = (el) => {
if (!el || el.shadowRoot) return;
const shadowroot = el.attachShadow({ mode: 'open' });
shadowroot.appendChild(shadowlink);
}
const Home = sinon.fake(() => <div ref={attachShadow}></div>);
const Shadow = sinon.fake(() => <div>Shadow Route</div>);
render(
<LocationProvider>
<Router>
<Route path="/" component={Home} />
<Route path="/shadow" component={Shadow}/>
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
shadowlink.click();
await sleep(1);
expect(loc).to.deep.include({ url: '/shadow' });
expect(Shadow).to.have.been.calledOnce;
expect(scratch).to.have.property('textContent', 'Shadow Route');
});
it('should not preserve param state after match failures', async () => {
const Params = () => {
const { params } = useRoute();
return <h1>{JSON.stringify(params)}</h1>
};
render(
<LocationProvider>
<Router>
<Route path="/category/:id" component={Params} />
<Route path="/category/:categoryId/products/new" component={Params} />
<Route path="/category/:categoryId/products/:id/edit" component={Params} />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
navigation.navigate('/category/123');
await sleep(10);
expect(scratch).to.have.property('textContent', '{"id":"123"}');
navigation.navigate('/category/123/products/new');
await sleep(10);
// If the same `params` object was reused, this would also have an `id` property
// from a failed partial match against the first route.
expect(scratch).to.have.property('textContent', '{"categoryId":"123"}');
navigation.navigate('/category/123/products/456/edit');
await sleep(10);
expect(scratch).to.have.property('textContent', '{"categoryId":"123","id":"456"}');
});
it('should support navigating backwards and forwards', async () => {
render(
<LocationProvider>
<Router>
<Route path="/" component={() => null} />
<Route path="/foo" component={() => null} />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
navigation.navigate('/foo');
await sleep(10);
expect(loc).to.deep.include({ url: '/foo', path: '/foo', query: {} });
await navigation.back().finished;
await sleep(10);
expect(loc).to.deep.include({ url: '/', path: '/', query: {} });
await navigation.forward().finished;
await sleep(10);
expect(loc).to.deep.include({ url: '/foo', path: '/foo', query: {} });
});
});
const MODE_HYDRATE = 1 << 5;
const MODE_SUSPENDED = 1 << 7;
describe('hydration', () => {
let scratch;
beforeEach(() => {
if (scratch) {
render(null, scratch);
scratch.remove();
}
scratch = document.createElement('scratch');
document.body.appendChild(scratch);
history.replaceState(null, null, '/');
});
it('should wait for asynchronous routes', async () => {
scratch.innerHTML = '<div><h1>A</h1><p>hello</p></div>';
const route = name => (
<div>
<h1>{name}</h1>
<p>hello</p>
</div>
);
const A = sinon.fake(groggy(() => route('A'), 1));
hydrate(
<ErrorBoundary>
<LocationProvider>
<Router>
<A path="/" />
</Router>
</LocationProvider>
</ErrorBoundary>,
scratch
);
const mutations = [];
const mutationObserver = new MutationObserver((x) => {
mutations.push(...x)
});
mutationObserver.observe(scratch, { childList: true, subtree: true });
expect(scratch).to.have.property('innerHTML', '<div><h1>A</h1><p>hello</p></div>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
const oldOptionsVnode = options.__b;
let hasMatched = false;
options.__b = (vnode) => {
if (vnode.type === A && !hasMatched) {
hasMatched = true;
if (vnode.__ && vnode.__.__h) {
expect(vnode.__.__h).to.equal(true)
} else if (vnode.__ && vnode.__.__u) {
expect(!!(vnode.__.__u & MODE_SUSPENDED)).to.equal(true);
expect(!!(vnode.__.__u & MODE_HYDRATE)).to.equal(true);
} else {
expect(true).to.equal(false);
}
}
if (oldOptionsVnode) {
oldOptionsVnode(vnode);
}
}
A.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<div><h1>A</h1><p>hello</p></div>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(mutations).to.have.length(0);
options.__b = oldOptionsVnode;
});
})
================================================
FILE: test/router.test.js
================================================
import { h, Fragment, render, Component, hydrate, options } from 'preact';
import { useState } from 'preact/hooks';
import * as chai from 'chai';
import * as sinon from 'sinon';
import sinonChai from 'sinon-chai';
import { LocationProvider, Router, useLocation, Route, useRoute } from '../src/router.js';
import lazy, { ErrorBoundary } from '../src/lazy.js';
import './setup.js';
const expect = chai.expect;
chai.use(sinonChai);
/**
* Usage:
* - `await sleep(1)` for nav + loc/pushState/sync component check
* - `await sleep(10)` for nav + async component check
*/
const sleep = ms => new Promise(r => setTimeout(r, ms));
// delayed lazy()
const groggy = (component, ms) => lazy(() => sleep(ms).then(() => component));
describe('Router', () => {
let scratch, loc;
const ShallowLocation = () => {
loc = useLocation();
return null;
}
beforeEach(() => {
if (scratch) {
render(null, scratch);
scratch.remove();
}
loc = undefined;
scratch = document.createElement('scratch');
document.body.appendChild(scratch);
history.replaceState(null, null, '/');
});
it('should throw a clear error if the LocationProvider is missing', () => {
const Home = () => <h1>Home</h1>;
try {
render(
<Router>
<Home path="/" test="2" />
</Router>,
scratch
);
expect.fail('should have thrown');
} catch (e) {
expect(e.message).to.include('must be used within a <LocationProvider>');
}
});
it('should strip trailing slashes from path', async () => {
render(
<LocationProvider url="/a/">
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(loc).to.deep.include({
url: '/a/',
path: '/a',
query: {},
});
});
it('should support class components using LocationProvider.ctx', () => {
class Foo extends Component {
static contextType = LocationProvider.ctx;
render() {
loc = this.context;
return <h1>{loc.url}</h1>;
}
}
render(
<LocationProvider>
<Foo />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('innerHTML', '<h1>/</h1>');
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
});
it('should allow passing props to a route', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
render(
<LocationProvider>
<Router>
<Home path="/" test="2" />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
});
it('should allow updating props in a route', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
/** @type {(string) => void} */
let set;
const App = () => {
const [test, setTest] = useState('2');
set = setTest;
return (
<LocationProvider>
<Router>
<Home path="/" test={test} />
</Router>
<ShallowLocation />
</LocationProvider>
);
}
render(<App />, scratch);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '2' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
set('3')
await sleep(1);
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '', test: '3' });
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
expect(scratch).to.have.property('textContent', 'Home');
});
it('should switch between synchronous routes', async () => {
const Home = sinon.fake(() => <h1>Home</h1>);
const Profiles = sinon.fake(() => <h1>Profiles</h1>);
const Profile = sinon.fake(({ params }) => <h1>Profile: {params.id}</h1>);
const Fallback = sinon.fake(() => <h1>Fallback</h1>);
const stack = [];
render(
<LocationProvider>
<Router onRouteChange={url => stack.push(url)}>
<Home path="/" />
<Profiles path="/profiles" />
<Profile path="/profiles/:id" />
<Fallback default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scratch).to.have.property('textContent', 'Home');
expect(Home).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(Profiles).not.to.have.been.called;
expect(Profile).not.to.have.been.called;
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/',
path: '/',
query: {},
});
Home.resetHistory();
loc.route('/profiles');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Profiles');
expect(Home).not.to.have.been.called;
expect(Profiles).to.have.been.calledWith({ path: '/profiles', query: {}, params: {}, rest: '' });
expect(Profile).not.to.have.been.called;
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/profiles',
path: '/profiles',
query: {}
});
Profiles.resetHistory();
loc.route('/profiles/bob');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Profile: bob');
expect(Home).not.to.have.been.called;
expect(Profiles).not.to.have.been.called;
expect(Profile).to.have.been.calledWith(
{ path: '/profiles/bob', query: {}, params: { id: 'bob' }, id: 'bob', rest: '' },
);
expect(Fallback).not.to.have.been.called;
expect(loc).to.deep.include({
url: '/profiles/bob',
path: '/profiles/bob',
query: {}
});
Profile.resetHistory();
loc.route('/other?a=b&c=d');
await sleep(1);
expect(scratch).to.have.property('textContent', 'Fallback');
expect(Home).not.to.have.been.called;
expect(Profiles).not.to.have.been.called;
expect(Profile).not.to.have.been.called;
expect(Fallback).to.have.been.calledWith(
{ default: true, path: '/other', query: { a: 'b', c: 'd' }, params: {}, rest: '' },
);
expect(loc).to.deep.include({
url: '/other?a=b&c=d',
path: '/other',
query: { a: 'b', c: 'd' }
});
expect(stack).to.eql(['/profiles', '/profiles/bob', '/other?a=b&c=d']);
});
it('should wait for asynchronous routes', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const B = sinon.fake(groggy(() => route('B'), 1));
const C = sinon.fake(groggy(() => <h1>C</h1>, 1));
render(
<ErrorBoundary>
<LocationProvider>
<Router>
<A path="/" />
<B path="/b" />
<C path="/c" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
A.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
A.resetHistory();
loc.route('/b');
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).not.to.have.been.called;
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
// We should never re-invoke <A /> while loading <B /> (that would be a remount of the old route):
expect(A).not.to.have.been.called;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
B.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(B).to.have.been.calledOnce;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
B.resetHistory();
loc.route('/c');
loc.route('/c?1');
loc.route('/c');
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(B).not.to.have.been.called;
await sleep(1);
loc.route('/c');
loc.route('/c?2');
loc.route('/c');
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
// We should never re-invoke <B /> while loading <C /> (that would be a remount of the old route):
expect(B).not.to.have.been.called;
expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' });
C.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>C</h1>');
expect(C).to.have.been.calledOnce;
expect(C).to.have.been.calledWith({ path: '/c', query: {}, params: {}, rest: '' });
// "instant" routing to already-loaded routes
C.resetHistory();
B.resetHistory();
loc.route('/b');
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(C).not.to.have.been.called;
expect(B).to.have.been.calledOnce;
expect(B).to.have.been.calledWith({ path: '/b', query: {}, params: {}, rest: '' });
A.resetHistory();
B.resetHistory();
loc.route('/');
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(B).not.to.have.been.called;
expect(A).to.have.been.calledOnce;
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
});
it('rerenders same-component routes rather than swap', async () => {
const A = sinon.fake(() => <h1>a</h1>);
const B = sinon.fake(groggy(({ sub }) => <h1>b/{sub}</h1>, 1));
// Counts the wrappers around route components to determine what the Router is returning
// Count will be 2 for switching route components, and 2 more if the new route is lazily loaded
// A same-route navigation adds 1
let renderRefCount = 0;
const old = options.vnode;
options.vnode = (vnode) => {
if (typeof vnode.type === 'function' && vnode.props.r !== undefined) {
renderRefCount += 1;
}
if (old) old(vnode);
}
render(
<ErrorBoundary>
<LocationProvider>
<Router>
<A path="/" />
<B path="/b/:sub" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '<h1>a</h1>');
expect(renderRefCount).to.equal(2);
renderRefCount = 0;
loc.route('/b/a');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>b/a</h1>');
expect(renderRefCount).to.equal(4);
renderRefCount = 0;
loc.route('/b/b');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>b/b</h1>');
expect(renderRefCount).to.equal(1);
renderRefCount = 0;
loc.route('/');
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>a</h1>');
expect(renderRefCount).to.equal(2);
options.vnode = old;
});
it('should support onLoadStart/onLoadEnd/onRouteChange w/out navigation', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const loadStart = sinon.fake();
const loadEnd = sinon.fake();
const routeChange = sinon.fake();
render(
<ErrorBoundary>
<LocationProvider>
<Router
onLoadStart={loadStart}
onLoadEnd={loadEnd}
onRouteChange={routeChange}
>
<A path="/" />
</Router>
</LocationProvider>
</ErrorBoundary>,
scratch
);
expect(scratch).to.have.property('innerHTML', '');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(loadStart).to.have.been.calledWith('/');
expect(loadEnd).not.to.have.been.called;
expect(routeChange).not.to.have.been.called;
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
await sleep(1);
expect(scratch).to.have.property('innerHTML', '<h1>A</h1><p>hello</p>');
expect(A).to.have.been.calledWith({ path: '/', query: {}, params: {}, rest: '' });
expect(loadStart).not.to.have.been.called;
expect(loadEnd).to.have.been.calledWith('/');
expect(routeChange).not.to.have.been.called;
});
it('should support onLoadStart/onLoadEnd/onRouteChange w/ navigation', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(() => route('A'));
const B = sinon.fake(groggy(() => route('B'), 1));
const loadStart = sinon.fake();
const loadEnd = sinon.fake();
const routeChange = sinon.fake();
render(
<ErrorBoundary>
<LocationProvider>
<Router
onLoadStart={loadStart}
onLoadEnd={loadEnd}
onRouteChange={routeChange}
>
<A path="/" />
<B path="/b" />
</Router>
<ShallowLocation />
</LocationProvider>
</ErrorBoundary>,
scratch
);
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
loc.route('/b');
await sleep(1);
expect(loadStart).to.have.been.calledWith('/b');
expect(loadEnd).not.to.have.been.called;
expect(routeChange).not.to.have.been.called;
A.resetHistory();
loadStart.resetHistory();
loadEnd.resetHistory();
routeChange.resetHistory();
await sleep(10);
expect(scratch).to.have.property('innerHTML', '<h1>B</h1><p>hello</p>');
expect(loadStart).not.to.have.been.called;
expect(loadEnd).to.have.been.calledWith('/b');
expect(routeChange).to.have.been.calledWith('/b');
});
it('should only call onLoadEnd once upon promise flush', async () => {
const route = name => (
<>
<h1>{name}</h1>
<p>hello</p>
</>
);
const A = sinon.fake(groggy(() => route('A'), 1));
const loadEnd = sinon.fake();
/** @type {(string) => void} */
let set;
const App = () => {
set = useState('1')[1];
return (
<ErrorBoundary>
<LocationProvider>
<Router onLoadEnd={loadEnd}>
<A path="/" />
</Router>
</LocationProvider>
</ErrorBoundary>
);
}
render(<App />, scratch);
await sleep(10);
expect(loadEnd).to.have.been.calledWith('/');
loadEnd.resetHistory();
set('2');
await sleep(1);
expect(loadEnd).not.to.have.been.called;
});
describe('intercepted VS external links', () => {
const shouldIntercept = [null, '', '_self', 'self', '_SELF'];
const shouldNavigate = ['_top', '_parent', '_blank', 'custom', '_BLANK'];
const clickHandler = sinon.fake(e => e.preventDefault());
const Route = sinon.fake(
() => <div>
{[...shouldIntercept, ...shouldNavigate].map((target, i) => {
const url = '/' + i + '/' + target;
if (target === null) return <a href={url}>target = {target + ''}</a>;
return <a href={url} target={target}>target = {target}</a>;
})}
</div>
);
let pushState;
before(() => {
pushState = sinon.spy(history, 'pushState');
addEventListener('click', clickHandler);
});
after(() => {
pushState.restore();
removeEventListener('click', clickHandler);
});
beforeEach(async () => {
render(
<LocationProvider>
<Router>
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
Route.resetHistory();
clickHandler.resetHistory();
pushState.resetHistory();
});
const getName = target => (target == null ? 'no target attribute' : `target="${target}"`);
// these should all be intercepted by the router.
for (const target of shouldIntercept) {
it(`should intercept clicks on links with ${getName(target)}`, async () => {
const sel = target == null ? `a:not([target])` : `a[target="${target}"]`;
const el = scratch.querySelector(sel);
if (!el) throw Error(`Unable to find link: ${sel}`);
const url = el.getAttribute('href');
el.click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(Route).to.have.been.calledOnce;
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
});
}
// these should all navigate.
for (const target of shouldNavigate) {
it(`should allow default browser navigation for links with ${getName(target)}`, async () => {
const sel = target == null ? `a:not([target])` : `a[target="${target}"]`;
const el = scratch.querySelector(sel);
if (!el) throw Error(`Unable to find link: ${sel}`);
el.click();
await sleep(1);
expect(Route).not.to.have.been.called;
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
});
}
});
describe('intercepted VS external links with `scope`', () => {
const shouldIntercept = ['/app', '/app/deeper'];
const shouldNavigate = ['/site', '/site/deeper'];
const clickHandler = sinon.fake(e => e.preventDefault());
const Links = () => (
<>
<a href="/app">Internal Link</a>
<a href="/app/deeper">Internal Deeper Link</a>
<a href="/site">External Link</a>
<a href="/site/deeper">External Deeper Link</a>
</>
);
let pushState;
before(() => {
pushState = sinon.spy(history, 'pushState');
addEventListener('click', clickHandler);
});
after(() => {
pushState.restore();
removeEventListener('click', clickHandler);
});
beforeEach(async () => {
clickHandler.resetHistory();
pushState.resetHistory();
});
it('should intercept clicks on links matching the `scope` props (string)', async () => {
render(
<LocationProvider scope="/app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldIntercept) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should allow default browser navigation for links not matching the `scope` props (string)', async () => {
render(
<LocationProvider scope="app">
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldNavigate) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should intercept clicks on links matching the `scope` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldIntercept) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(loc).to.deep.include({ url });
expect(pushState).to.have.been.calledWith(null, '', url);
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
it('should allow default browser navigation for links not matching the `scope` props (regex)', async () => {
render(
<LocationProvider scope={/^\/app/}>
<Links />
<ShallowLocation />
</LocationProvider>,
scratch
);
for (const url of shouldNavigate) {
scratch.querySelector(`a[href="${url}"]`).click();
await sleep(1);
expect(pushState).not.to.have.been.called;
expect(clickHandler).to.have.been.called;
pushState.resetHistory();
clickHandler.resetHistory();
}
});
});
it('should scroll to top when navigating forward', async () => {
const scrollTo = sinon.spy(window, 'scrollTo');
const Route = sinon.fake(() => <div style={{ height: '1000px' }}><a href="/link">link</a></div>);
render(
<LocationProvider>
<Router>
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(scrollTo).not.to.have.been.called;
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
loc.route('/programmatic');
await sleep(1);
expect(loc).to.deep.include({ url: '/programmatic' });
expect(scrollTo).to.have.been.calledWith(0, 0);
expect(scrollTo).to.have.been.calledOnce;
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scrollTo.resetHistory();
scratch.querySelector('a').click();
await sleep(1);
expect(loc).to.deep.include({ url: '/link' });
expect(scrollTo).to.have.been.calledWith(0, 0);
expect(scrollTo).to.have.been.calledOnce;
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scrollTo.restore();
});
it('should ignore clicks on document fragment links', async () => {
const pushState = sinon.spy(history, 'pushState');
const Route = sinon.fake(
() => <div>
<a href="#foo">just #foo</a>
<a href="/other#bar">other #bar</a>
</div>
);
render(
<LocationProvider>
<Router>
<Route path="/" />
<Route path="/other" />
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scratch.querySelector('a[href="#foo"]').click();
await sleep(1);
// NOTE: we don't (currently) propagate in-page anchor navigations into context, to avoid useless renders.
expect(loc).to.deep.include({ url: '/' });
expect(Route).not.to.have.been.called;
expect(pushState).not.to.have.been.called;
expect(location.hash).to.equal('#foo');
scratch.querySelector('a[href="/other#bar"]').click();
await sleep(1);
expect(Route).to.have.been.calledOnce;
expect(loc).to.deep.include({ url: '/other#bar', path: '/other' });
expect(pushState).to.have.been.called;
expect(location.hash).to.equal('#bar');
pushState.restore();
});
it('should ignore clicks on download links', async () => {
const downloadHref = URL.createObjectURL(new Blob(['Hello World!'], { type: 'text/plain' }));
render(
<LocationProvider>
<Router>
<a href={downloadHref} download="hello-world.txt">Download Me</a>
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
scratch.querySelector('a[download]').click();
await sleep(1);
// If the router attempted to navigate, the page would throw a SecurityError
// and the test would fail.
expect(true).to.equal(true);
});
it('should normalize children', async () => {
const pushState = sinon.spy(history, 'pushState');
const Route = sinon.fake(() => <a href="/foo#foo">foo</a>);
const routes = ['/foo', '/bar'];
render(
<LocationProvider>
<Router>
{routes.map(route => <Route path={route} />)}
<Route default />
</Router>
<ShallowLocation />
</LocationProvider>,
scratch
);
expect(Route).to.have.been.calledOnce;
Route.resetHistory();
scratch.querySelector('a[href="/foo#foo"]').click();
await sleep(10);
expect(Route).to.have.been.calledOnce;
expect(loc).to.deep.include({ url: '/foo#foo', path: '/foo' });
expect(pushState).to.have.been.called;
pushState.restore();
});
it('should match nested routes', async () => {
let route;
const Inner = () => (
<Router>
<Route
gitextract_qae3wprk/ ├── .editorconfig ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ └── feature_request.md │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── jsconfig.json ├── package.json ├── polyglot-utils/ │ ├── .gitignore │ ├── README.md │ ├── go/ │ │ ├── README.md │ │ ├── go.mod │ │ ├── preact_iso_url_pattern.go │ │ └── preact_iso_url_pattern_test.go │ ├── php/ │ │ ├── README.md │ │ ├── preact-iso-url-pattern.php │ │ └── test_preact_iso_url_pattern.php │ ├── python/ │ │ ├── README.md │ │ ├── preact_iso_url_pattern.py │ │ └── test_preact_iso_url_pattern.py │ ├── ruby/ │ │ ├── README.md │ │ ├── preact-iso-url-pattern.rb │ │ └── test_preact_iso_url_pattern.rb │ └── run_tests.sh ├── src/ │ ├── hydrate.d.ts │ ├── hydrate.js │ ├── index.d.ts │ ├── index.js │ ├── internal.d.ts │ ├── lazy.d.ts │ ├── lazy.js │ ├── prerender.d.ts │ ├── prerender.js │ ├── router-navigation-api.d.ts │ ├── router-navigation-api.js │ ├── router.d.ts │ └── router.js ├── test/ │ ├── lazy.test.js │ ├── node/ │ │ ├── location-stub.test.js │ │ ├── pattern-match.types.ts │ │ ├── prerender.test.js │ │ └── router-match.test.js │ ├── router-navigation-api.test.js │ ├── router.test.js │ └── setup.js └── web-test-runner.config.js
SYMBOL INDEX (211 symbols across 23 files)
FILE: polyglot-utils/go/preact_iso_url_pattern.go
type Matches (line 12) | type Matches struct
function preactIsoUrlPatternMatch (line 17) | func preactIsoUrlPatternMatch(urlStr, route string, matches *Matches) *M...
function filterEmpty (line 89) | func filterEmpty(s []string) []string {
function max (line 99) | func max(a, b int) int {
FILE: polyglot-utils/go/preact_iso_url_pattern_test.go
function TestPreactIsoUrlPatternMatch (line 12) | func TestPreactIsoUrlPatternMatch(t *testing.T) {
function TestDebugSpecificCase (line 468) | func TestDebugSpecificCase(t *testing.T) {
function TestFilterEmpty (line 493) | func TestFilterEmpty(t *testing.T) {
FILE: polyglot-utils/php/preact-iso-url-pattern.php
function safeUrldecode (line 5) | function safeUrldecode($str) {
function preactIsoUrlPatternMatch (line 19) | function preactIsoUrlPatternMatch($url, $route, $matches = null) {
FILE: polyglot-utils/php/test_preact_iso_url_pattern.php
class TestPreactIsoUrlPatternMatch (line 9) | class TestPreactIsoUrlPatternMatch {
method run (line 14) | public function run() {
method runTest (line 34) | private function runTest($methodName) {
method assertEqual (line 47) | private function assertEqual($expected, $actual, $message = '') {
method assertNull (line 59) | private function assertNull($actual, $message = '') {
method assertNotNull (line 66) | private function assertNotNull($actual, $message = '') {
method test_base_route_exact_match (line 75) | public function test_base_route_exact_match() {
method test_base_route_no_match (line 81) | public function test_base_route_no_match() {
method test_param_route_match (line 87) | public function test_param_route_match() {
method test_param_route_no_match (line 93) | public function test_param_route_no_match() {
method test_rest_segment_match (line 99) | public function test_rest_segment_match() {
method test_rest_segment_match_multiple_segments (line 105) | public function test_rest_segment_match_multiple_segments() {
method test_rest_segment_no_match (line 111) | public function test_rest_segment_no_match() {
method test_rest_segment_no_match_different_case (line 116) | public function test_rest_segment_no_match_different_case() {
method test_param_with_rest_single_segment (line 122) | public function test_param_with_rest_single_segment() {
method test_param_with_rest_multiple_segments (line 128) | public function test_param_with_rest_multiple_segments() {
method test_param_with_rest_no_match (line 134) | public function test_param_with_rest_no_match() {
method test_optional_param_empty (line 140) | public function test_optional_param_empty() {
method test_optional_param_no_match_base (line 146) | public function test_optional_param_no_match_base() {
method test_optional_rest_param_empty (line 152) | public function test_optional_rest_param_empty() {
method test_optional_rest_param_with_segments (line 158) | public function test_optional_rest_param_with_segments() {
method test_optional_param_no_match_base_duplicate (line 164) | public function test_optional_param_no_match_base_duplicate() {
method test_required_rest_param_single_segment (line 170) | public function test_required_rest_param_single_segment() {
method test_required_rest_param_multiple_segments (line 176) | public function test_required_rest_param_multiple_segments() {
method test_required_rest_param_empty_should_fail (line 182) | public function test_required_rest_param_empty_should_fail() {
method test_required_rest_param_root_mismatch (line 187) | public function test_required_rest_param_root_mismatch() {
method test_leading_trailing_slashes (line 193) | public function test_leading_trailing_slashes() {
method test_url_encoded_param (line 201) | public function test_url_encoded_param() {
method test_url_encoded_email_in_param (line 207) | public function test_url_encoded_email_in_param() {
method test_rest_segment_with_encoded_parts (line 214) | public function test_rest_segment_with_encoded_parts() {
method test_empty_route (line 221) | public function test_empty_route() {
method test_empty_url_with_param (line 226) | public function test_empty_url_with_param() {
method test_mixed_required_and_optional_params (line 231) | public function test_mixed_required_and_optional_params() {
method test_mixed_required_and_optional_params_missing_optional (line 237) | public function test_mixed_required_and_optional_params_missing_option...
method test_pre_existing_matches_object (line 244) | public function test_pre_existing_matches_object() {
method test_complex_nested_path_with_multiple_params (line 253) | public function test_complex_nested_path_with_multiple_params() {
method test_route_longer_than_url_required_param_missing (line 262) | public function test_route_longer_than_url_required_param_missing() {
method test_route_longer_than_url_optional_param (line 267) | public function test_route_longer_than_url_optional_param() {
method test_multiple_slashes_in_url_should_be_normalized (line 273) | public function test_multiple_slashes_in_url_should_be_normalized() {
method test_route_with_multiple_slashes (line 279) | public function test_route_with_multiple_slashes() {
method test_complex_url_encoding_in_rest_params (line 287) | public function test_complex_url_encoding_in_rest_params() {
method test_special_characters_encoded_in_url (line 293) | public function test_special_characters_encoded_in_url() {
method test_unicode_characters_encoded (line 301) | public function test_unicode_characters_encoded() {
method test_empty_segments_in_middle_of_url (line 307) | public function test_empty_segments_in_middle_of_url() {
method test_route_with_only_wildcards (line 313) | public function test_route_with_only_wildcards() {
method test_malformed_percent_encoding_simple_param (line 320) | public function test_malformed_percent_encoding_simple_param() {
method test_malformed_percent_encoding_rest_param (line 327) | public function test_malformed_percent_encoding_rest_param() {
method test_invalid_unicode_sequence (line 334) | public function test_invalid_unicode_sequence() {
FILE: polyglot-utils/python/preact_iso_url_pattern.py
function safe_unquote (line 6) | def safe_unquote(s):
function preact_iso_url_pattern_match (line 15) | def preact_iso_url_pattern_match(url, route, matches=None):
FILE: polyglot-utils/python/test_preact_iso_url_pattern.py
class TestPreactIsoUrlPatternMatch (line 8) | class TestPreactIsoUrlPatternMatch(unittest.TestCase):
method test_base_route_exact_match (line 11) | def test_base_route_exact_match(self):
method test_base_route_no_match (line 17) | def test_base_route_no_match(self):
method test_param_route_match (line 23) | def test_param_route_match(self):
method test_param_route_no_match (line 29) | def test_param_route_no_match(self):
method test_rest_segment_match (line 35) | def test_rest_segment_match(self):
method test_rest_segment_match_multiple_segments (line 41) | def test_rest_segment_match_multiple_segments(self):
method test_rest_segment_no_match (line 47) | def test_rest_segment_no_match(self):
method test_rest_segment_no_match_different_case (line 52) | def test_rest_segment_no_match_different_case(self):
method test_param_with_rest_single_segment (line 58) | def test_param_with_rest_single_segment(self):
method test_param_with_rest_multiple_segments (line 64) | def test_param_with_rest_multiple_segments(self):
method test_param_with_rest_no_match (line 70) | def test_param_with_rest_no_match(self):
method test_optional_param_empty (line 76) | def test_optional_param_empty(self):
method test_optional_param_no_match_base (line 82) | def test_optional_param_no_match_base(self):
method test_optional_rest_param_empty (line 88) | def test_optional_rest_param_empty(self):
method test_optional_rest_param_with_segments (line 94) | def test_optional_rest_param_with_segments(self):
method test_optional_param_no_match_base_duplicate (line 100) | def test_optional_param_no_match_base_duplicate(self):
method test_required_rest_param_single_segment (line 106) | def test_required_rest_param_single_segment(self):
method test_required_rest_param_multiple_segments (line 112) | def test_required_rest_param_multiple_segments(self):
method test_required_rest_param_empty_should_fail (line 118) | def test_required_rest_param_empty_should_fail(self):
method test_required_rest_param_root_mismatch (line 123) | def test_required_rest_param_root_mismatch(self):
method test_leading_trailing_slashes (line 129) | def test_leading_trailing_slashes(self):
method test_url_encoded_param (line 137) | def test_url_encoded_param(self):
method test_url_encoded_email_in_param (line 143) | def test_url_encoded_email_in_param(self):
method test_rest_segment_with_encoded_parts (line 150) | def test_rest_segment_with_encoded_parts(self):
method test_empty_route (line 157) | def test_empty_route(self):
method test_empty_url_with_param (line 162) | def test_empty_url_with_param(self):
method test_mixed_required_and_optional_params (line 167) | def test_mixed_required_and_optional_params(self):
method test_mixed_required_and_optional_params_missing_optional (line 173) | def test_mixed_required_and_optional_params_missing_optional(self):
method test_pre_existing_matches_object (line 180) | def test_pre_existing_matches_object(self):
method test_complex_nested_path_with_multiple_params (line 188) | def test_complex_nested_path_with_multiple_params(self):
method test_route_longer_than_url_required_param_missing (line 197) | def test_route_longer_than_url_required_param_missing(self):
method test_route_longer_than_url_optional_param (line 202) | def test_route_longer_than_url_optional_param(self):
method test_multiple_slashes_in_url_should_be_normalized (line 208) | def test_multiple_slashes_in_url_should_be_normalized(self):
method test_route_with_multiple_slashes (line 214) | def test_route_with_multiple_slashes(self):
method test_complex_url_encoding_in_rest_params (line 220) | def test_complex_url_encoding_in_rest_params(self):
method test_special_characters_encoded_in_url (line 226) | def test_special_characters_encoded_in_url(self):
method test_unicode_characters_encoded (line 232) | def test_unicode_characters_encoded(self):
method test_empty_segments_in_middle_of_url (line 238) | def test_empty_segments_in_middle_of_url(self):
method test_route_with_only_wildcards (line 244) | def test_route_with_only_wildcards(self):
class TestUrlDecodingErrorHandling (line 251) | class TestUrlDecodingErrorHandling(unittest.TestCase):
method test_malformed_percent_encoding_simple_param (line 254) | def test_malformed_percent_encoding_simple_param(self):
method test_malformed_percent_encoding_rest_param (line 261) | def test_malformed_percent_encoding_rest_param(self):
method test_invalid_unicode_sequence (line 267) | def test_invalid_unicode_sequence(self):
FILE: polyglot-utils/ruby/preact-iso-url-pattern.rb
function safe_cgi_unescape (line 5) | def safe_cgi_unescape(str)
function preact_iso_url_pattern_match (line 16) | def preact_iso_url_pattern_match(url, route, matches = nil)
FILE: polyglot-utils/ruby/test_preact_iso_url_pattern.rb
class TestPreactIsoUrlPatternMatch (line 7) | class TestPreactIsoUrlPatternMatch < Minitest::Test
method test_base_route_exact_match (line 10) | def test_base_route_exact_match
method test_base_route_no_match (line 17) | def test_base_route_no_match
method test_param_route_match (line 24) | def test_param_route_match
method test_param_route_no_match (line 31) | def test_param_route_no_match
method test_rest_segment_match (line 38) | def test_rest_segment_match
method test_rest_segment_match_multiple_segments (line 45) | def test_rest_segment_match_multiple_segments
method test_rest_segment_no_match (line 52) | def test_rest_segment_no_match
method test_rest_segment_no_match_different_case (line 58) | def test_rest_segment_no_match_different_case
method test_param_with_rest_single_segment (line 65) | def test_param_with_rest_single_segment
method test_param_with_rest_multiple_segments (line 72) | def test_param_with_rest_multiple_segments
method test_param_with_rest_no_match (line 79) | def test_param_with_rest_no_match
method test_optional_param_empty (line 86) | def test_optional_param_empty
method test_optional_param_no_match_base (line 93) | def test_optional_param_no_match_base
method test_optional_rest_param_empty (line 100) | def test_optional_rest_param_empty
method test_optional_rest_param_with_segments (line 107) | def test_optional_rest_param_with_segments
method test_optional_param_no_match_base_duplicate (line 114) | def test_optional_param_no_match_base_duplicate
method test_required_rest_param_single_segment (line 121) | def test_required_rest_param_single_segment
method test_required_rest_param_multiple_segments (line 128) | def test_required_rest_param_multiple_segments
method test_required_rest_param_empty_should_fail (line 135) | def test_required_rest_param_empty_should_fail
method test_required_rest_param_root_mismatch (line 141) | def test_required_rest_param_root_mismatch
method test_leading_trailing_slashes (line 148) | def test_leading_trailing_slashes
method test_url_encoded_param (line 157) | def test_url_encoded_param
method test_url_encoded_email_in_param (line 164) | def test_url_encoded_email_in_param
method test_rest_segment_with_encoded_parts (line 172) | def test_rest_segment_with_encoded_parts
method test_empty_route (line 180) | def test_empty_route
method test_empty_url_with_param (line 186) | def test_empty_url_with_param
method test_mixed_required_and_optional_params (line 192) | def test_mixed_required_and_optional_params
method test_mixed_required_and_optional_params_missing_optional (line 199) | def test_mixed_required_and_optional_params_missing_optional
method test_pre_existing_matches_object (line 207) | def test_pre_existing_matches_object
method test_complex_nested_path_with_multiple_params (line 216) | def test_complex_nested_path_with_multiple_params
method test_route_longer_than_url_required_param_missing (line 226) | def test_route_longer_than_url_required_param_missing
method test_route_longer_than_url_optional_param (line 232) | def test_route_longer_than_url_optional_param
method test_multiple_slashes_in_url_should_be_normalized (line 240) | def test_multiple_slashes_in_url_should_be_normalized
method test_route_with_multiple_slashes (line 247) | def test_route_with_multiple_slashes
method test_rest_param_with_single_character (line 254) | def test_rest_param_with_single_character
method test_complex_url_encoding_in_rest_params (line 261) | def test_complex_url_encoding_in_rest_params
method test_special_characters_encoded_in_url (line 268) | def test_special_characters_encoded_in_url
method test_unicode_characters_encoded (line 275) | def test_unicode_characters_encoded
method test_empty_segments_in_middle_of_url (line 282) | def test_empty_segments_in_middle_of_url
method test_route_with_only_wildcards (line 289) | def test_route_with_only_wildcards
class TestUrlDecodingErrorHandling (line 298) | class TestUrlDecodingErrorHandling < Minitest::Test
method test_malformed_percent_encoding_simple_param (line 301) | def test_malformed_percent_encoding_simple_param
method test_malformed_percent_encoding_rest_param (line 309) | def test_malformed_percent_encoding_rest_param
method test_invalid_unicode_sequence (line 316) | def test_invalid_unicode_sequence
FILE: src/hydrate.js
function hydrate (line 6) | function hydrate(jsx, parent) {
FILE: src/index.js
function prerender (line 5) | function prerender(vnode, options) {
FILE: src/internal.d.ts
type AugmentedComponent (line 4) | interface AugmentedComponent extends Component<any, any> {
type VNode (line 9) | interface VNode<P = any> extends preact.VNode<P> {
FILE: src/lazy.js
function lazy (line 13) | function lazy(load) {
function ErrorBoundary (line 57) | function ErrorBoundary(props) {
function childDidSuspend (line 63) | function childDidSuspend(err) {
FILE: src/prerender.d.ts
type PrerenderOptions (line 3) | interface PrerenderOptions {
type PrerenderResult (line 7) | interface PrerenderResult {
FILE: src/prerender.js
function prerender (line 17) | async function prerender(vnode, options) {
function locationStub (line 49) | function locationStub(path) {
FILE: src/router-navigation-api.d.ts
type NestedArray (line 8) | type NestedArray<T> = Array<T | NestedArray<T>>;
type KnownProps (line 10) | interface KnownProps {
type ArbitraryProps (line 19) | interface ArbitraryProps {
type MatchProps (line 23) | type MatchProps = KnownProps & ArbitraryProps;
type LocationHook (line 41) | interface LocationHook {
type RouteHook (line 48) | interface RouteHook {
type RoutableProps (line 55) | type RoutableProps =
type RouteProps (line 59) | type RouteProps<Props> = RoutableProps & { component: AnyComponent<Props...
type RoutePropsForPath (line 61) | type RoutePropsForPath<Path extends string> = Path extends '*'
type IntrinsicAttributes (line 97) | interface IntrinsicAttributes extends RoutableProps {}
type Attributes (line 100) | interface Attributes extends RoutableProps {}
FILE: src/router-navigation-api.js
function isSameWindow (line 13) | function isSameWindow(e) {
function isInScope (line 29) | function isInScope(url) {
function handleNav (line 40) | function handleNav(state, e) {
function LocationProvider (line 93) | function LocationProvider(props) {
constant RESOLVED (line 118) | const RESOLVED = Promise.resolve();
function Router (line 120) | function Router(props) {
constant MODE_HYDRATE (line 272) | const MODE_HYDRATE = 1 << 5;
constant MODE_SUSPENDED (line 273) | const MODE_SUSPENDED = 1 << 7;
FILE: src/router.d.ts
type NestedArray (line 8) | type NestedArray<T> = Array<T | NestedArray<T>>;
type KnownProps (line 10) | interface KnownProps {
type ArbitraryProps (line 19) | interface ArbitraryProps {
type MatchProps (line 23) | type MatchProps = KnownProps & ArbitraryProps;
type LocationHook (line 41) | interface LocationHook {
type RouteHook (line 51) | interface RouteHook {
type RoutableProps (line 58) | type RoutableProps =
type RouteProps (line 62) | type RouteProps<Props> = RoutableProps & { component: AnyComponent<Props...
type RoutePropsForPath (line 64) | type RoutePropsForPath<Path extends string> = Path extends '*'
type IntrinsicAttributes (line 100) | interface IntrinsicAttributes extends RoutableProps {}
type Attributes (line 103) | interface Attributes extends RoutableProps {}
FILE: src/router.js
function isInScope (line 19) | function isInScope(href) {
function handleNav (line 30) | function handleNav(state, action) {
function LocationProvider (line 101) | function LocationProvider(props) {
constant RESOLVED (line 140) | const RESOLVED = Promise.resolve();
function Router (line 142) | function Router(props) {
constant MODE_HYDRATE (line 295) | const MODE_HYDRATE = 1 << 5;
constant MODE_SUSPENDED (line 296) | const MODE_SUSPENDED = 1 << 7;
FILE: test/node/pattern-match.types.ts
type isEqualsType (line 8) | type isEqualsType<T, U> = T extends U ? U extends T ? true : false : false;
type isWeakEqualsType (line 9) | type isWeakEqualsType<T, U> = T extends U ? true : false;
FILE: test/node/router-match.test.js
function execPath (line 6) | function execPath(path, pattern, opts) {
FILE: test/router-navigation-api.test.js
class Foo (line 80) | class Foo extends Component {
method render (line 83) | render() {
function useSuspense (line 913) | function useSuspense() {
FILE: test/router.test.js
class Foo (line 77) | class Foo extends Component {
method render (line 80) | render() {
function useSuspense (line 983) | function useSuspense() {
FILE: test/setup.js
function patchConsole (line 11) | function patchConsole(method) {
function serializeConsoleArgs (line 27) | function serializeConsoleArgs(args) {
function applyIndent (line 44) | function applyIndent(n) {
function serialize (line 56) | function serialize(value, mode, indent, seen) {
Condensed preview — 48 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (226K chars).
[
{
"path": ".editorconfig",
"chars": 363,
"preview": "root = true\n\n[*]\nindent_style = tab\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ninsert_final_newlin"
},
{
"path": ".github/ISSUE_TEMPLATE/bug_report.md",
"chars": 790,
"preview": "---\nname: Bug report\nabout: Create a report to help us improve\ntitle: ''\nlabels: ''\nassignees: ''\n---\n\n- [ ] Check if up"
},
{
"path": ".github/ISSUE_TEMPLATE/feature_request.md",
"chars": 347,
"preview": "---\nname: Feature request\nabout: Suggest an idea for this project\ntitle: ''\nlabels: feature request\nassignees: ''\n---\n\n*"
},
{
"path": ".github/workflows/ci.yml",
"chars": 401,
"preview": "name: CI\n\non:\n push:\n branches:\n - main\n pull_request:\n branches:\n - '**'\n\njobs:\n build_test:\n nam"
},
{
"path": ".gitignore",
"chars": 369,
"preview": "# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.\n\n# dependencies\n/node_modules\n/.pn"
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 3215,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nIn the interest of fostering an open and welcoming environment, w"
},
{
"path": "LICENSE",
"chars": 1075,
"preview": "MIT License\n\nCopyright (c) 2020 The Preact Authors\n\nPermission is hereby granted, free of charge, to any person obtainin"
},
{
"path": "README.md",
"chars": 19089,
"preview": "# preact-iso\n\n[]"
},
{
"path": "jsconfig.json",
"chars": 287,
"preview": "{\n \"compilerOptions\": {\n \"target\": \"ESNext\",\n \"module\": \"NodeNext\",\n \"moduleResolution\": \"NodeNext\",\n \"noEm"
},
{
"path": "package.json",
"chars": 1411,
"preview": "{\n\t\"name\": \"preact-iso\",\n\t\"version\": \"2.12.0\",\n\t\"type\": \"module\",\n\t\"main\": \"src/index.js\",\n\t\"module\": \"src/index.js\",\n\t\""
},
{
"path": "polyglot-utils/.gitignore",
"chars": 76,
"preview": "# Language-specific files to ignore\n__pycache__/\n*.pyc\n.venv/\n.ruby-version\n"
},
{
"path": "polyglot-utils/README.md",
"chars": 5604,
"preview": "# Preact ISO URL Pattern Matching - Polyglot Utils\n\nMulti-language implementations of URL pattern matching utilities for"
},
{
"path": "polyglot-utils/go/README.md",
"chars": 1458,
"preview": "# Go Implementation\n\nURL pattern matching utility for Go servers.\n\n## Setup\n\nCode tested on Go 1.24.x.\n\n```sh\n# If using"
},
{
"path": "polyglot-utils/go/go.mod",
"chars": 26,
"preview": "module myproject\n\ngo 1.13\n"
},
{
"path": "polyglot-utils/go/preact_iso_url_pattern.go",
"chars": 2821,
"preview": "// Run program: go run preact-iso-url-pattern.go\n\npackage main\n\nimport (\n\t// \"fmt\"\n\t\"net/url\"\n\t\"regexp\"\n\t\"strings\"\n)\n\nty"
},
{
"path": "polyglot-utils/go/preact_iso_url_pattern_test.go",
"chars": 12560,
"preview": "package main\n\nimport (\n\t\"reflect\"\n\t\"strings\"\n\t\"testing\"\n)\n\n// Note 1: This is different from JS implementation. Here it "
},
{
"path": "polyglot-utils/php/README.md",
"chars": 1822,
"preview": "# PHP Implementation\n\nURL pattern matching utility for PHP servers.\n\n## Setup\n\nCode tested on PHP 8.3.x.\n\n```sh\nphp --ve"
},
{
"path": "polyglot-utils/php/preact-iso-url-pattern.php",
"chars": 2454,
"preview": "<?php\n// Run program: php preact-iso-url-pattern.php\n\n// Safe URL decode function with error handling\nfunction safeUrlde"
},
{
"path": "polyglot-utils/php/test_preact_iso_url_pattern.php",
"chars": 13759,
"preview": "<?php\n/**\n * Test suite for preact-iso-url-pattern.php - ported from Go tests\n * Run with: php test_preact_iso_url_patte"
},
{
"path": "polyglot-utils/python/README.md",
"chars": 1692,
"preview": "# Python Implementation\n\nURL pattern matching utility for Python servers.\n\n## Setup\n\nCode tested on Python 3.12.x.\n\nNo e"
},
{
"path": "polyglot-utils/python/preact_iso_url_pattern.py",
"chars": 2281,
"preview": "# Run program: python3 preact-iso-url-pattern.py\n\nfrom urllib.parse import unquote\n\n# Safe URL decode function with erro"
},
{
"path": "polyglot-utils/python/test_preact_iso_url_pattern.py",
"chars": 12401,
"preview": "#!/usr/bin/env python3\n\"\"\"Test suite for preact_iso_url_pattern.py - ported from Go tests\"\"\"\n\nimport unittest\nfrom preac"
},
{
"path": "polyglot-utils/ruby/README.md",
"chars": 1595,
"preview": "# Ruby Implementation\n\nURL pattern matching utility for Ruby servers.\n\n## Setup\n\nCode tested on ruby 3.2.x.\n\n```sh\nruby "
},
{
"path": "polyglot-utils/ruby/preact-iso-url-pattern.rb",
"chars": 1850,
"preview": "# Run program: ruby preact-iso-url-pattern.rb\nrequire 'cgi'\n\n# Safe URL decode function with error handling\ndef safe_cgi"
},
{
"path": "polyglot-utils/ruby/test_preact_iso_url_pattern.rb",
"chars": 11691,
"preview": "#!/usr/bin/env ruby\n# Test suite for preact-iso-url-pattern.rb - ported from Go tests\n\nrequire 'minitest/autorun'\nrequir"
},
{
"path": "polyglot-utils/run_tests.sh",
"chars": 2247,
"preview": "#!/bin/bash\n\n# Preact ISO URL Pattern Matching - Test Runner\n# Runs tests for all language implementations\n\n# Colors for"
},
{
"path": "src/hydrate.d.ts",
"chars": 141,
"preview": "import { ComponentChild, ContainerNode } from 'preact';\n\nexport default function hydrate(jsx: ComponentChild, parent?: C"
},
{
"path": "src/hydrate.js",
"chars": 485,
"preview": "import { render, hydrate as hydrativeRender } from 'preact';\n\nlet initialized;\n\n/** @type {typeof hydrativeRender} */\nex"
},
{
"path": "src/index.d.ts",
"chars": 195,
"preview": "export { default as prerender } from './prerender.js';\nexport * from './router.js';\nexport { default as lazy, ErrorBound"
},
{
"path": "src/index.js",
"chars": 315,
"preview": "export { Router, LocationProvider, useLocation, Route, useRoute } from './router.js';\nexport { default as lazy, ErrorBou"
},
{
"path": "src/internal.d.ts",
"chars": 426,
"preview": "/// <reference types=\"navigation-api-types\" />\nimport { Component } from 'preact';\n\nexport interface AugmentedComponent "
},
{
"path": "src/lazy.d.ts",
"chars": 277,
"preview": "import { ComponentChildren, VNode } from 'preact';\n\nexport default function lazy<T>(load: () => Promise<{ default: T } |"
},
{
"path": "src/lazy.js",
"chars": 1604,
"preview": "import { h, options } from 'preact';\nimport { useState, useRef } from 'preact/hooks';\n\nconst oldDiff = options.__b;\nopti"
},
{
"path": "src/prerender.d.ts",
"chars": 338,
"preview": "import { VNode } from 'preact';\n\nexport interface PrerenderOptions {\n\tprops?: Record<string, unknown>;\n}\n\nexport interfa"
},
{
"path": "src/prerender.js",
"chars": 1426,
"preview": "import { h, options, cloneElement } from 'preact';\nimport { renderToStringAsync } from 'preact-render-to-string';\n\nlet v"
},
{
"path": "src/router-navigation-api.d.ts",
"chars": 3397,
"preview": "import { AnyComponent, ComponentChildren, Context, VNode } from 'preact';\n\nexport const LocationProvider: {\n\t(props: { s"
},
{
"path": "src/router-navigation-api.js",
"chars": 8739,
"preview": "import { h, createContext, cloneElement, toChildArray } from 'preact';\nimport { useContext, useMemo, useReducer, useLayo"
},
{
"path": "src/router.d.ts",
"chars": 3488,
"preview": "import { AnyComponent, ComponentChildren, Context, VNode } from 'preact';\n\nexport const LocationProvider: {\n\t(props: { s"
},
{
"path": "src/router.js",
"chars": 9525,
"preview": "import { h, createContext, cloneElement, toChildArray } from 'preact';\nimport { useContext, useMemo, useReducer, useLayo"
},
{
"path": "test/lazy.test.js",
"chars": 1499,
"preview": "import { h, render } from 'preact';\nimport * as chai from 'chai';\nimport * as sinon from 'sinon';\nimport sinonChai from "
},
{
"path": "test/node/location-stub.test.js",
"chars": 889,
"preview": "import { test } from 'uvu';\nimport * as assert from 'uvu/assert';\n\nimport { locationStub } from '../../src/prerender.js'"
},
{
"path": "test/node/pattern-match.types.ts",
"chars": 2567,
"preview": "// Test this file by running:\n// npx tsc --noEmit test/node/pattern-match.types.ts\n\nimport type { RoutePropsForPath } fr"
},
{
"path": "test/node/prerender.test.js",
"chars": 859,
"preview": "import { test } from 'uvu';\nimport * as assert from 'uvu/assert';\nimport { html } from 'htm/preact';\n\nimport { default a"
},
{
"path": "test/node/router-match.test.js",
"chars": 4174,
"preview": "import { test } from 'uvu';\nimport * as assert from 'uvu/assert';\n\nimport { exec } from '../../src/router.js';\n\nfunction"
},
{
"path": "test/router-navigation-api.test.js",
"chars": 28966,
"preview": "import { h, Fragment, render, Component, hydrate, options } from 'preact';\nimport { useState } from 'preact/hooks';\nimpo"
},
{
"path": "test/router.test.js",
"chars": 29878,
"preview": "import { h, Fragment, render, Component, hydrate, options } from 'preact';\nimport { useState } from 'preact/hooks';\nimpo"
},
{
"path": "test/setup.js",
"chars": 4076,
"preview": "import kl from 'kleur';\n\n/*\n * Custom serializer borrowed from the Preact repo as the default in wtr\n * is busted when i"
},
{
"path": "web-test-runner.config.js",
"chars": 259,
"preview": "import { esbuildPlugin } from \"@web/dev-server-esbuild\";\n\nexport default {\n\tnodeResolve: true,\n\ttestsFinishTimeout: 3000"
}
]
About this extraction
This page contains the full source code of the preactjs/preact-iso GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 48 files (200.4 KB), approximately 56.3k tokens, and a symbol index with 211 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.