├── .editorconfig
├── .github
├── FUNDING.yml
├── dependabot.yml
└── workflows
│ └── pr.yml
├── .gitignore
├── .npmignore
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── LICENSE
├── README.md
├── cypress.config.mjs
├── cypress
├── .gitignore
├── e2e
│ ├── child-outlets.cy.js
│ ├── error-handler.cy.js
│ ├── guards-resolve-route.cy.js
│ ├── guards.cy.js
│ ├── hash-error-handler.cy.js
│ ├── hash-guards-resolve-route.cy.js
│ ├── hash-guards.cy.js
│ ├── hash-redirects.cy.js
│ ├── hash-url-params.cy.js
│ ├── hash.cy.js
│ ├── lazy-views.cy.js
│ ├── login.cy.js
│ ├── redirects.cy.js
│ ├── routed-app.cy.js
│ ├── sticky-outlets.cy.js
│ └── url-params.cy.js
├── fixtures
│ └── example.json
├── plugins
│ └── index.cjs
└── support
│ ├── commands.js
│ └── e2e.js
├── dist
├── router.mjs
└── router.mjs.map
├── favicon.ico
├── img
├── ficus-icon-512x512.png
├── ficus-icon-optimised.svg
└── ficus-icon.svg
├── package-lock.json
├── package.json
├── src
├── router.mjs
└── util
│ ├── add-matcher-to-route.mjs
│ ├── element-empty.mjs
│ ├── element-from-string.mjs
│ ├── emit.mjs
│ ├── flatten-routes.mjs
│ ├── is-element.mjs
│ ├── is-promise.mjs
│ ├── object-has-key.mjs
│ ├── render-outlet.mjs
│ ├── slashes.mjs
│ ├── url-search-params.mjs
│ └── wait-for.mjs
├── test
└── e2e
│ ├── css
│ └── styles.css
│ ├── index.html
│ ├── lib
│ ├── index.mjs
│ └── lit-html.mjs
│ ├── routed-app-child-outlets
│ ├── app.js
│ ├── contents
│ │ ├── bar.js
│ │ ├── baz.js
│ │ └── foo.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-error-handler
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-guards-resolve-route
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ └── page-one.js
│ ├── routed-app-guards
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ └── page-one.js
│ ├── routed-app-hash-error-handler
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-hash-guards-resolve-route
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ └── page-one.js
│ ├── routed-app-hash-guards
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ └── page-one.js
│ ├── routed-app-hash-login
│ ├── app.js
│ ├── components
│ │ └── nav.js
│ ├── index.html
│ ├── main.js
│ ├── router
│ │ ├── router.js
│ │ └── routes.js
│ ├── store
│ │ └── store.js
│ └── views
│ │ ├── home-page.js
│ │ ├── loggedin-page.js
│ │ ├── login-page.js
│ │ ├── page-error.js
│ │ ├── page-not-found.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-hash-params
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ ├── page-two.js
│ │ └── user-page.js
│ ├── routed-app-hash-redirects
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ └── page-two.js
│ ├── routed-app-hash-url-params
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ ├── page-two.js
│ │ └── user-page.js
│ ├── routed-app-hash
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-lazy-views
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.esm.js
│ │ └── page-two.js
│ ├── routed-app-login
│ ├── app.js
│ ├── components
│ │ └── nav.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ ├── store.js
│ └── views
│ │ ├── home-page.js
│ │ ├── index.js
│ │ ├── loggedin-page.js
│ │ ├── login-page.js
│ │ ├── page-not-found.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-no-auto-start-path
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-no-auto-start
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-redirects
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ └── page-two.js
│ ├── routed-app-sticky-outlets
│ ├── app.js
│ ├── contents
│ │ ├── bar.js
│ │ ├── baz.js
│ │ └── foo.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ ├── routed-app-url-params
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ ├── page-two.js
│ │ └── user-page.js
│ ├── routed-app
│ ├── app.js
│ ├── index.html
│ ├── main.js
│ ├── router.js
│ └── views
│ │ ├── home-page.js
│ │ ├── page-one.js
│ │ └── page-two.js
│ └── util
│ ├── component.js
│ ├── detect.js
│ ├── emit.js
│ ├── find-parent.js
│ ├── methods.js
│ └── wait-for.js
└── types
└── router.d.ts
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | [*]
4 | end_of_line = lf
5 | insert_final_newline = false
6 | trim_trailing_whitespace = true
7 | indent_size = 2
8 | indent_style = space
9 | charset = utf-8
10 |
--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | # These are supported funding model platforms
2 |
3 | github: [ficusjs]
4 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: "npm"
4 | directory: "/"
5 | schedule:
6 | interval: "weekly"
7 |
8 | - package-ecosystem: "npm"
9 | directory: "/site"
10 | schedule:
11 | interval: "weekly"
12 |
--------------------------------------------------------------------------------
/.github/workflows/pr.yml:
--------------------------------------------------------------------------------
1 | name: PR checks
2 |
3 | on:
4 | pull_request:
5 | branches:
6 | - main
7 | types: [opened, synchronize, edited]
8 |
9 | jobs:
10 | checks:
11 | runs-on: ubuntu-latest
12 | steps:
13 | - uses: actions/checkout@v3
14 | - name: Setup Node.js
15 | uses: actions/setup-node@v3
16 | with:
17 | node-version: 18.x
18 | - name: Install dependencies
19 | run: npm install
20 | - name: Lint
21 | run: npm run lint
22 | - name: Run tests
23 | run: npm test
24 | - name: Build
25 | run: npm run build
26 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | test/snapshots
3 | .DS_Store
4 | .idea
5 | .vscode
6 | *.log
7 | tmp
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | .github
2 | site
3 | test
4 | examples
5 | cypress
6 | img
7 | .editorconfig
8 | .idea
9 | .vscode
10 | cypress.json
11 | favicon.ico
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # Changelog
2 |
3 | All notable changes to this project will be documented in this file.
4 |
5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 |
8 | ## [3.4.2] - 2024-10-10
9 |
10 | ### Updates
11 | - Uplift devDependencies
12 |
13 | ## [3.4.1] - 2023-07-31
14 |
15 | ### Updates
16 | - Uplift devDependencies
17 |
18 | ## [3.4.0] - 2022-10-25
19 |
20 | ### Updates
21 | - Generate sourcemaps on build
22 |
23 | ## [3.3.2] - 2022-10-25
24 |
25 | ### Updates
26 | - Uplift devDependencies
27 |
28 | ## [3.3.1] - 2022-10-18
29 |
30 | ### Fixes
31 | - Fix `addMatcherToRoute` issue with slashes
32 |
33 | ## [3.3.0] - 2022-10-18
34 |
35 | ### New
36 | - Export `addMatcherToRoute` function for adding a matcher to a route
37 |
38 | ## [3.2.0] - 2022-10-02
39 |
40 | ### New
41 | - Emit `outlet-change` event when an outlet is populated
42 |
43 | ### Updates
44 | - Uplift devDependencies
45 | - Convert e2e tests to Cypress v10.x
46 |
47 | ## [3.1.1] - 2021-11-16
48 |
49 | ### Fixes
50 | - Fix types for `createRouter` function
51 |
52 | ## [3.1.0] - 2021-10-20
53 |
54 | ### Updates
55 | - Add `sideEffects` to package.json
56 | - Uplift devDependencies
57 |
58 | ## [3.0.0] - 2021-06-10
59 |
60 | ### Breaking
61 | - Make package `type: module`
62 | - Rename build file to `router.mjs`
63 |
64 | ### New
65 | - Allow routes to be cancelled
66 |
67 | ### Updates
68 | - Add guards to documentation
69 | - Uplift NPM dependencies
70 |
71 | ## [2.2.0] - 2021-05-07
72 |
73 | ### New
74 | - Allow `sticky` attribute for persistent named outlets
75 |
76 | ## [2.1.0] - 2021-04-29
77 |
78 | ### Updates
79 | - Uplift NPM dev dependencies
80 |
81 | ## [2.0.2] - 2021-03-04
82 |
83 | ### Fixes
84 | - Missing package.json properties
85 |
86 | ## [2.0.1] - 2021-03-04
87 |
88 | ### Fixes
89 | - Add exports map to package.json
90 |
91 | ### Updates
92 | - Uplift dev dependencies
93 |
94 | ## [2.0.0] - 2021-02-06
95 |
96 | ### Changed
97 | - Rename package to @ficusjs/router
98 |
99 | ## [1.1.2] - 2020-10-06
100 |
101 | ### Fixed
102 | - Wait for child outlets before continuing render
103 |
104 | ## [1.1.1] - 2020-10-06
105 |
106 | ### Fixed
107 | - Ensure an outlet is clear before rendering contents
108 |
109 | ## [1.1.0] - 2020-09-30
110 |
111 | ### Fixed
112 | - Rename private properties
113 |
114 | ### Added
115 | - Cache outlets to avoid re-rendering the same content
116 |
117 | ## [1.0.0] - 2020-09-29
118 |
119 | - Initial release
120 |
121 | ## [0.1.0] - 2020-09-29
122 |
123 | - Beta release
124 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | We as members, contributors, and leaders pledge to make participation in our
6 | community a harassment-free experience for everyone, regardless of age, body
7 | size, visible or invisible disability, ethnicity, sex characteristics, gender
8 | identity and expression, level of experience, education, socio-economic status,
9 | nationality, personal appearance, race, religion, or sexual identity
10 | and orientation.
11 |
12 | We pledge to act and interact in ways that contribute to an open, welcoming,
13 | diverse, inclusive, and healthy community.
14 |
15 | ## Our Standards
16 |
17 | Examples of behavior that contributes to a positive environment for our
18 | community include:
19 |
20 | * Demonstrating empathy and kindness toward other people
21 | * Being respectful of differing opinions, viewpoints, and experiences
22 | * Giving and gracefully accepting constructive feedback
23 | * Accepting responsibility and apologizing to those affected by our mistakes,
24 | and learning from the experience
25 | * Focusing on what is best not just for us as individuals, but for the
26 | overall community
27 |
28 | Examples of unacceptable behavior include:
29 |
30 | * The use of sexualized language or imagery, and sexual attention or
31 | advances of any kind
32 | * Trolling, insulting or derogatory comments, and personal or political attacks
33 | * Public or private harassment
34 | * Publishing others' private information, such as a physical or email
35 | address, without their explicit permission
36 | * Other conduct which could reasonably be considered inappropriate in a
37 | professional setting
38 |
39 | ## Enforcement Responsibilities
40 |
41 | Community leaders are responsible for clarifying and enforcing our standards of
42 | acceptable behavior and will take appropriate and fair corrective action in
43 | response to any behavior that they deem inappropriate, threatening, offensive,
44 | or harmful.
45 |
46 | Community leaders have the right and responsibility to remove, edit, or reject
47 | comments, commits, code, wiki edits, issues, and other contributions that are
48 | not aligned to this Code of Conduct, and will communicate reasons for moderation
49 | decisions when appropriate.
50 |
51 | ## Scope
52 |
53 | This Code of Conduct applies within all community spaces, and also applies when
54 | an individual is officially representing the community in public spaces.
55 | Examples of representing our community include using an official e-mail address,
56 | posting via an official social media account, or acting as an appointed
57 | representative at an online or offline event.
58 |
59 | ## Enforcement
60 |
61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
62 | reported to the community leaders responsible for enforcement at
63 | .
64 | All complaints will be reviewed and investigated promptly and fairly.
65 |
66 | All community leaders are obligated to respect the privacy and security of the
67 | reporter of any incident.
68 |
69 | ## Enforcement Guidelines
70 |
71 | Community leaders will follow these Community Impact Guidelines in determining
72 | the consequences for any action they deem in violation of this Code of Conduct:
73 |
74 | ### 1. Correction
75 |
76 | **Community Impact**: Use of inappropriate language or other behavior deemed
77 | unprofessional or unwelcome in the community.
78 |
79 | **Consequence**: A private, written warning from community leaders, providing
80 | clarity around the nature of the violation and an explanation of why the
81 | behavior was inappropriate. A public apology may be requested.
82 |
83 | ### 2. Warning
84 |
85 | **Community Impact**: A violation through a single incident or series
86 | of actions.
87 |
88 | **Consequence**: A warning with consequences for continued behavior. No
89 | interaction with the people involved, including unsolicited interaction with
90 | those enforcing the Code of Conduct, for a specified period of time. This
91 | includes avoiding interactions in community spaces as well as external channels
92 | like social media. Violating these terms may lead to a temporary or
93 | permanent ban.
94 |
95 | ### 3. Temporary Ban
96 |
97 | **Community Impact**: A serious violation of community standards, including
98 | sustained inappropriate behavior.
99 |
100 | **Consequence**: A temporary ban from any sort of interaction or public
101 | communication with the community for a specified period of time. No public or
102 | private interaction with the people involved, including unsolicited interaction
103 | with those enforcing the Code of Conduct, is allowed during this period.
104 | Violating these terms may lead to a permanent ban.
105 |
106 | ### 4. Permanent Ban
107 |
108 | **Community Impact**: Demonstrating a pattern of violation of community
109 | standards, including sustained inappropriate behavior, harassment of an
110 | individual, or aggression toward or disparagement of classes of individuals.
111 |
112 | **Consequence**: A permanent ban from any sort of public interaction within
113 | the community.
114 |
115 | ## Attribution
116 |
117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage],
118 | version 2.0, available at
119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html.
120 |
121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct
122 | enforcement ladder](https://github.com/mozilla/diversity).
123 |
124 | [homepage]: https://www.contributor-covenant.org
125 |
126 | For answers to common questions about this code of conduct, see the FAQ at
127 | https://www.contributor-covenant.org/faq. Translations are available at
128 | https://www.contributor-covenant.org/translations.
129 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | When contributing to this repository, please first discuss the change you wish to make via issue,
4 | email, or any other method with the owners of this repository before making a change.
5 |
6 | Please note we have a code of conduct, please follow it in all your interactions with the project.
7 |
8 | ## Commits
9 |
10 | The Conventional Commits specification must be followed for commit messages.
11 |
12 | Conventional commits provide an easy set of rules for creating an explicit commit history; which makes it easier to write automated tools on top of. This convention dovetails with SemVer, by describing the features, fixes, and breaking changes made in commit messages.
13 |
14 | Please review [https://www.conventionalcommits.org/en/v1.0.0/](https://www.conventionalcommits.org/en/v1.0.0/) before making your first commit.
15 |
16 | ## Pull Request Process
17 |
18 | 1. Ensure any install or build dependencies are removed before the end of the layer when doing a
19 | build.
20 | 2. Update the README.md with details of changes to the interface, this includes new environment
21 | variables, exposed ports, useful file locations and container parameters.
22 | 3. Increase the version numbers in any examples files and the README.md to the new version that this
23 | Pull Request would represent. The versioning scheme we use is [SemVer](http://semver.org/).
24 | 4. You may merge the Pull Request in once you have the sign-off of two other developers, or if you
25 | do not have permission to do that, you may request the second reviewer to merge it for you.
26 |
27 | ## Code of Conduct
28 |
29 | ### Our Pledge
30 |
31 | In the interest of fostering an open and welcoming environment, we as
32 | contributors and maintainers pledge to making participation in our project and
33 | our community a harassment-free experience for everyone, regardless of age, body
34 | size, disability, ethnicity, gender identity and expression, level of experience,
35 | nationality, personal appearance, race, religion, or sexual identity and
36 | orientation.
37 |
38 | ### Our Standards
39 |
40 | Examples of behavior that contributes to creating a positive environment
41 | include:
42 |
43 | * Using welcoming and inclusive language
44 | * Being respectful of differing viewpoints and experiences
45 | * Gracefully accepting constructive criticism
46 | * Focusing on what is best for the community
47 | * Showing empathy towards other community members
48 |
49 | Examples of unacceptable behavior by participants include:
50 |
51 | * The use of sexualized language or imagery and unwelcome sexual attention or
52 | advances
53 | * Trolling, insulting/derogatory comments, and personal or political attacks
54 | * Public or private harassment
55 | * Publishing others' private information, such as a physical or electronic
56 | address, without explicit permission
57 | * Other conduct which could reasonably be considered inappropriate in a
58 | professional setting
59 |
60 | ### Our Responsibilities
61 |
62 | Project maintainers are responsible for clarifying the standards of acceptable
63 | behavior and are expected to take appropriate and fair corrective action in
64 | response to any instances of unacceptable behavior.
65 |
66 | Project maintainers have the right and responsibility to remove, edit, or
67 | reject comments, commits, code, wiki edits, issues, and other contributions
68 | that are not aligned to this Code of Conduct, or to ban temporarily or
69 | permanently any contributor for other behaviors that they deem inappropriate,
70 | threatening, offensive, or harmful.
71 |
72 | ### Scope
73 |
74 | This Code of Conduct applies both within project spaces and in public spaces
75 | when an individual is representing the project or its community. Examples of
76 | representing a project or community include using an official project e-mail
77 | address, posting via an official social media account, or acting as an appointed
78 | representative at an online or offline event. Representation of a project may be
79 | further defined and clarified by project maintainers.
80 |
81 | ### Enforcement
82 |
83 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
84 | reported by contacting the project team at [dev@ficusjs.org](dev@ficusjs.org). All
85 | complaints will be reviewed and investigated and will result in a response that
86 | is deemed necessary and appropriate to the circumstances. The project team is
87 | obligated to maintain confidentiality with regard to the reporter of an incident.
88 | Further details of specific enforcement policies may be posted separately.
89 |
90 | Project maintainers who do not follow or enforce the Code of Conduct in good
91 | faith may face temporary or permanent repercussions as determined by other
92 | members of the project's leadership.
93 |
94 | ### Attribution
95 |
96 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
97 | available at [http://contributor-covenant.org/version/1/4][version]
98 |
99 | [homepage]: http://contributor-covenant.org
100 | [version]: http://contributor-covenant.org/version/1/4/
101 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2021 Matt Levy
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # FicusJS router
2 |
3 |
4 |
5 | Lightweight standalone client-side router that supports history and hash routing.
6 |
7 | ## Features
8 |
9 | - Declarative route set-up
10 | - Two modes of routing - history and hash
11 | - Routes render web components or HTML strings
12 | - Routes can be sync or async
13 | - Provide context to routes
14 | - Handle redirects from routes
15 | - Named URL parameters
16 | - Guard navigations by redirecting or cancelling
17 | - Outlets act as placeholders for rendering content per route
18 | - Control navigation with methods
19 | - Lazy load views based on route
20 | - Start router programmatically
21 |
22 | ## Documentation
23 |
24 | See the [full documentation](https://docs.ficusjs.org/router).
25 |
26 | ## Getting started
27 |
28 | The easiest way to try out FicusJS router is using a simple example.
29 |
30 | Create an `index.html` file and copy the following between the `
` tags.
31 |
32 | ```html
33 |
34 |
35 |
36 |
86 | ```
87 |
88 | > Alternatively, fork this Codepen to see it in action - [https://codepen.io/ducksoupdev/pen/PoNvGwK](https://codepen.io/ducksoupdev/pen/PoNvGwK)
89 |
90 | The example creates a set of page components, a page navigation component and a new router using hash mode.
91 |
92 | ## Installation
93 |
94 | FicusJS router can be installed in a number of ways.
95 |
96 | ### CDN
97 |
98 | We recommend using native ES modules in the browser.
99 |
100 | ```html
101 |
104 | ```
105 |
106 | FicusJS router is available on [Skypack](https://www.skypack.dev/view/@ficusjs/router).
107 |
108 | ### NPM
109 |
110 | FicusJS router works nicely with build tools such as Webpack or Rollup. If you are using a NodeJS tool, you can install the NPM package.
111 |
112 | ```bash
113 | npm install @ficusjs/router
114 | ```
115 |
116 | ### Available builds
117 |
118 | FicusJS router is only available as an ES module. For legacy browsers or alternative modules such as CommonJS, it is recommended to use a build tool to transpile the code.
119 |
120 | ## Development
121 |
122 | How to set-up FicusJS router for local development.
123 |
124 | 1. Clone the repository:
125 |
126 | ```bash
127 | git clone https://github.com/ficusjs/ficusjs-router.git
128 | ```
129 |
130 | 2. Change the working directory
131 |
132 | ```bash
133 | cd ficusjs-router
134 | ```
135 |
136 | 3. Install dependencies
137 |
138 | ```bash
139 | npm install # or, yarn install
140 | ```
141 |
142 | 4. Run the local development server
143 |
144 | ```bash
145 | npm run dev # or, yarn dev
146 | ```
147 |
148 | That's it! Now open http://localhost:8080 to see a local app.
149 |
150 | ## License
151 |
152 | This project is licensed under the MIT License - see the [`LICENSE`](LICENSE) file for details.
153 |
154 | ## Contributing to FicusJS router
155 |
156 | Any kind of positive contribution is welcome! Please help us to grow by contributing to the project.
157 |
158 | If you wish to contribute, you can work on any features you think would enhance the library. After adding your code, please send us a Pull Request.
159 |
160 | > Please read [CONTRIBUTING](CONTRIBUTING.md) for details on our [CODE OF CONDUCT](CODE_OF_CONDUCT.md), and the process for submitting pull requests to us.
161 |
162 | ## Support
163 |
164 | We all need support and motivation. FicusJS is not an exception. Please give this project a ⭐️ to encourage and show that you liked it. Don't forget to leave a star ⭐️ before you move away.
165 |
166 | If you found the library helpful, please consider [sponsoring us](https://github.com/sponsors/ficusjs).
167 |
--------------------------------------------------------------------------------
/cypress.config.mjs:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'cypress'
2 |
3 | export default defineConfig({
4 | reporter: 'junit',
5 | reporterOptions: {
6 | mochaFile: 'cypress/reports/junit/ci-test-output-[hash].xml',
7 | jenkinsMode: true,
8 | rootSuiteTitle: 'FicusJS Router Integration Tests',
9 | testsuitesTitle: 'Cypress Tests'
10 | },
11 | video: false,
12 | e2e: {
13 | testIsolation: false
14 | }
15 | })
16 |
--------------------------------------------------------------------------------
/cypress/.gitignore:
--------------------------------------------------------------------------------
1 | videos
2 | screenshots
3 | reports
--------------------------------------------------------------------------------
/cypress/e2e/child-outlets.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Child outlets', () => {
3 | before(() => {
4 | cy.visit('routed-app-child-outlets')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 |
32 | it('should render foo contents in the view outlet', () => {
33 | cy.get('foo-contents')
34 | .should('contain.text', 'Foo contents')
35 | })
36 |
37 | it('should render text in the title outlet', () => {
38 | cy.get('#title')
39 | .should('contain.text', 'Page one title outlet')
40 | })
41 | })
42 |
43 | describe('route to page two', () => {
44 | before(() => {
45 | cy.get('button:nth-of-type(3)').click()
46 | })
47 |
48 | it('has rendered page two', () => {
49 | cy.get('#router-outlet')
50 | .should('contain.text', 'Welcome to page two!')
51 | })
52 |
53 | it('page two navigation is active', () => {
54 | cy.get('button.active')
55 | .should('have.text', 'Page two')
56 | })
57 |
58 | it('should render bar contents in the view outlet', () => {
59 | cy.get('bar-contents')
60 | .should('contain.text', 'Bar contents with param Matt is one')
61 | })
62 |
63 | it('should render baz contents in the view outlet', () => {
64 | cy.get('baz-contents')
65 | .should('contain.text', 'Baz contents with param Matt is one')
66 | })
67 |
68 | it('should render text in the title outlet', () => {
69 | cy.get('#title')
70 | .should('contain.text', 'Page two title outlet')
71 | })
72 | })
73 |
74 | describe('route to home from page two content', () => {
75 | before(() => {
76 | cy.get('nav button:nth-of-type(1)').click()
77 | })
78 |
79 | it('has rendered the home page', () => {
80 | cy.get('#router-outlet')
81 | .should('contain.text', 'Welcome to the home page!')
82 | })
83 |
84 | it('home page navigation is active', () => {
85 | cy.get('button.active')
86 | .should('have.text', 'Home')
87 | })
88 |
89 | it('should clear the text in the title outlet', () => {
90 | cy.get('#title')
91 | .should('be.empty')
92 | })
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/cypress/e2e/error-handler.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Routed errors', () => {
3 | before(() => {
4 | cy.visit('routed-app-error-handler')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to fake page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered an error', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Page not found: /test/e2e/routed-app-error-handler/fake')
73 | })
74 | })
75 |
76 | describe('route to error page', () => {
77 | before(() => {
78 | cy.get('button:nth-of-type(5)').click()
79 | })
80 |
81 | it('has rendered an error', () => {
82 | cy.get('#router-outlet')
83 | .should('contain.text', 'An error occurred: Action error')
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/cypress/e2e/guards-resolve-route.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Guards - resolveRoute', () => {
3 | before(() => {
4 | cy.visit('routed-app-guards-resolve-route')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to cancelled page', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has remained on page one', () => {
39 | cy.location().should((loc) => {
40 | expect(loc.pathname).to.eq('/test/e2e/routed-app-guards-resolve-route/one')
41 | })
42 | })
43 | })
44 |
45 | describe('route to error page', () => {
46 | before(() => {
47 | cy.get('button:nth-of-type(4)').click()
48 | })
49 |
50 | it('has rendered an error', () => {
51 | cy.get('#router-outlet')
52 | .should('contain.text', 'An error occurred: Action error')
53 | })
54 | })
55 |
56 | describe('route to page two', () => {
57 | before(() => {
58 | cy.get('button:nth-of-type(5)').click()
59 | })
60 |
61 | it('has redirected to page one', () => {
62 | cy.location().should((loc) => {
63 | expect(loc.pathname).to.eq('/test/e2e/routed-app-guards-resolve-route/one')
64 | })
65 | })
66 |
67 | it('has rendered page one', () => {
68 | cy.get('#router-outlet')
69 | .should('contain.text', 'Welcome to page one!')
70 | })
71 |
72 | it('page one navigation is active', () => {
73 | cy.get('button.active')
74 | .should('have.text', 'Page one')
75 | })
76 | })
77 | })
78 |
--------------------------------------------------------------------------------
/cypress/e2e/guards.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Guards', () => {
3 | before(() => {
4 | cy.visit('routed-app-guards')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to cancelled page', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has remained on page one', () => {
39 | cy.location().should((loc) => {
40 | expect(loc.pathname).to.eq('/test/e2e/routed-app-guards/one')
41 | })
42 | })
43 | })
44 |
45 | describe('route to error page', () => {
46 | before(() => {
47 | cy.get('button:nth-of-type(4)').click()
48 | })
49 |
50 | it('has rendered an error', () => {
51 | cy.get('#router-outlet')
52 | .should('contain.text', 'An error occurred: Action error')
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/cypress/e2e/hash-error-handler.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash routed errors', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash-error-handler')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to fake page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered an error', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Page not found: /fake')
73 | })
74 | })
75 |
76 | describe('route to error page', () => {
77 | before(() => {
78 | cy.get('button:nth-of-type(5)').click()
79 | })
80 |
81 | it('has rendered an error', () => {
82 | cy.get('#router-outlet')
83 | .should('contain.text', 'An error occurred: Action error')
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/cypress/e2e/hash-guards-resolve-route.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash guards - resolveRoute', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash-guards-resolve-route')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to cancelled page', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has remained on page one', () => {
39 | cy.location().should((loc) => {
40 | expect(loc.hash).to.eq('#/one')
41 | })
42 | })
43 | })
44 |
45 | describe('route to error page', () => {
46 | before(() => {
47 | cy.get('button:nth-of-type(4)').click()
48 | })
49 |
50 | it('has rendered an error', () => {
51 | cy.get('#router-outlet')
52 | .should('contain.text', 'An error occurred: Action error')
53 | })
54 | })
55 |
56 | describe('route to page two', () => {
57 | before(() => {
58 | cy.get('button:nth-of-type(5)').click()
59 | })
60 |
61 | it('has redirected to page one', () => {
62 | cy.location().should((loc) => {
63 | expect(loc.hash).to.eq('#/one')
64 | })
65 | })
66 |
67 | it('has rendered page one', () => {
68 | cy.get('#router-outlet')
69 | .should('contain.text', 'Welcome to page one!')
70 | })
71 |
72 | it('page one navigation is active', () => {
73 | cy.get('button.active')
74 | .should('have.text', 'Page one')
75 | })
76 | })
77 | })
78 |
--------------------------------------------------------------------------------
/cypress/e2e/hash-guards.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash Ggards', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash-guards')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to cancelled page', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has remained on page one', () => {
39 | cy.location().should((loc) => {
40 | expect(loc.hash).to.eq('#/one')
41 | })
42 | })
43 | })
44 |
45 | describe('route to error page', () => {
46 | before(() => {
47 | cy.get('button:nth-of-type(4)').click()
48 | })
49 |
50 | it('has rendered an error', () => {
51 | cy.get('#router-outlet')
52 | .should('contain.text', 'An error occurred: Action error')
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/cypress/e2e/hash-redirects.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash redirects', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash-redirects')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page two', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page two!')
25 | })
26 |
27 | it('page two navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page two')
30 | })
31 |
32 | it('page two location is active', () => {
33 | cy.location('pathname')
34 | .should('equal', '/test/e2e/routed-app-hash-redirects/')
35 | cy.location('hash')
36 | .should('equal', '#/two')
37 | })
38 | })
39 | })
40 |
--------------------------------------------------------------------------------
/cypress/e2e/hash-url-params.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash URL params', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash-url-params')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to user page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered the user page', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Welcome matt to the user page!')
73 | })
74 | })
75 |
76 | describe('route to task page', () => {
77 | before(() => {
78 | cy.get('button:nth-of-type(5)').click()
79 | })
80 |
81 | it('has rendered the user page with task information', () => {
82 | cy.get('#router-outlet')
83 | .should('contain.text', 'Task 12345')
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/cypress/e2e/hash.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Hash routed app', () => {
3 | before(() => {
4 | cy.visit('routed-app-hash')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to fake page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered an error', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Router error: not_found')
73 | })
74 | })
75 | })
76 |
--------------------------------------------------------------------------------
/cypress/e2e/lazy-views.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Lazy views', () => {
3 | before(() => {
4 | cy.visit('routed-app-lazy-views')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 | })
65 |
--------------------------------------------------------------------------------
/cypress/e2e/login.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Login', () => {
3 | before(() => {
4 | cy.visit('routed-app-login')
5 | })
6 |
7 | it('has rendered the login page', () => {
8 | cy.get('button[type="submit"]')
9 | .should('have.text', 'Login')
10 | cy.get('input[type="email"]')
11 | .should('exist')
12 | cy.get('input[type="password"]')
13 | .should('exist')
14 | })
15 |
16 | it('does not have a logout button', () => {
17 | cy.get('nav button:last')
18 | .should('contain.text', 'Fake page')
19 | })
20 |
21 | describe('logging in', () => {
22 | before(() => {
23 | cy.get('input[type="email"]').type('test@test.com')
24 | cy.get('input[type="password"]').type('test')
25 | cy.get('button[type="submit"]').click()
26 | })
27 |
28 | it('has rendered the home page', () => {
29 | cy.get('#router-outlet')
30 | .should('contain.text', 'You are logged in test@test.com')
31 | })
32 |
33 | it('home page navigation is active', () => {
34 | cy.get('button.active')
35 | .should('have.text', 'Home')
36 | })
37 |
38 | it('has a logout button', () => {
39 | cy.get('nav button:last')
40 | .should('have.text', 'Logout')
41 | })
42 |
43 | describe('route to page one', () => {
44 | before(() => {
45 | cy.get('button:nth-of-type(2)').click()
46 | })
47 |
48 | it('has rendered page one', () => {
49 | cy.get('#router-outlet')
50 | .should('contain.text', 'Welcome to page one!')
51 | })
52 |
53 | it('page one navigation is active', () => {
54 | cy.get('button.active')
55 | .should('have.text', 'Page one')
56 | })
57 | })
58 |
59 | describe('route to page two', () => {
60 | before(() => {
61 | cy.get('button:nth-of-type(3)').click()
62 | })
63 |
64 | it('has rendered page two', () => {
65 | cy.get('#router-outlet')
66 | .should('contain.text', 'Welcome to page two!')
67 | })
68 |
69 | it('page two navigation is active', () => {
70 | cy.get('button.active')
71 | .should('have.text', 'Page two')
72 | })
73 | })
74 |
75 | describe('logout', () => {
76 | before(() => {
77 | cy.get('nav button:last').click()
78 | })
79 |
80 | it('has rendered the login page', () => {
81 | cy.get('button[type="submit"]')
82 | .should('have.text', 'Login')
83 | cy.get('input[type="email"]')
84 | .should('exist')
85 | cy.get('input[type="password"]')
86 | .should('exist')
87 | })
88 |
89 | it('does not have a logout button', () => {
90 | cy.get('nav button:last')
91 | .should('have.text', 'Fake page')
92 | })
93 | })
94 | })
95 | })
96 |
--------------------------------------------------------------------------------
/cypress/e2e/redirects.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Redirects', () => {
3 | before(() => {
4 | cy.visit('routed-app-redirects')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page two', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page two!')
25 | })
26 |
27 | it('page two navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page two')
30 | })
31 |
32 | it('page two location is active', () => {
33 | cy.location('pathname')
34 | .should('equal', '/test/e2e/routed-app-redirects/two')
35 | })
36 | })
37 | })
38 |
--------------------------------------------------------------------------------
/cypress/e2e/routed-app.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Routed app', () => {
3 | before(() => {
4 | cy.visit('routed-app')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to fake page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered an error', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Router error: not_found')
73 | })
74 | })
75 | })
76 |
--------------------------------------------------------------------------------
/cypress/e2e/sticky-outlets.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('Sticky outlets', () => {
3 | before(() => {
4 | cy.visit('routed-app-sticky-outlets')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 |
32 | it('should render foo contents in the view outlet', () => {
33 | cy.get('foo-contents')
34 | .should('contain.text', 'Foo contents')
35 | })
36 |
37 | it('should keep sticky text in the title outlet', () => {
38 | cy.get('#title')
39 | .should('contain.text', 'Home title outlet')
40 | })
41 | })
42 |
43 | describe('route to page two', () => {
44 | before(() => {
45 | cy.get('button:nth-of-type(3)').click()
46 | })
47 |
48 | it('has rendered page two', () => {
49 | cy.get('#router-outlet')
50 | .should('contain.text', 'Welcome to page two!')
51 | })
52 |
53 | it('page two navigation is active', () => {
54 | cy.get('button.active')
55 | .should('have.text', 'Page two')
56 | })
57 |
58 | it('should render bar contents in the view outlet', () => {
59 | cy.get('bar-contents')
60 | .should('contain.text', 'Bar contents with param Matt is one')
61 | })
62 |
63 | it('should render baz contents in the view outlet', () => {
64 | cy.get('baz-contents')
65 | .should('contain.text', 'Baz contents with param Matt is one')
66 | })
67 |
68 | it('should render text in the title outlet', () => {
69 | cy.get('#title')
70 | .should('contain.text', 'Page two title outlet')
71 | })
72 | })
73 |
74 | describe('route to home from page two content', () => {
75 | before(() => {
76 | cy.get('nav button:nth-of-type(1)').click()
77 | })
78 |
79 | it('has rendered the home page', () => {
80 | cy.get('#router-outlet')
81 | .should('contain.text', 'Welcome to the home page!')
82 | })
83 |
84 | it('home page navigation is active', () => {
85 | cy.get('button.active')
86 | .should('have.text', 'Home')
87 | })
88 |
89 | it('should render text in the title outlet', () => {
90 | cy.get('#title')
91 | .should('contain.text', 'Home title outlet')
92 | })
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/cypress/e2e/url-params.cy.js:
--------------------------------------------------------------------------------
1 | /* global describe cy before it */
2 | describe('URL params', () => {
3 | before(() => {
4 | cy.visit('routed-app-url-params')
5 | })
6 |
7 | it('has rendered the home page', () => {
8 | cy.get('#router-outlet')
9 | .should('contain.text', 'Welcome to the home page!')
10 | })
11 |
12 | it('home page navigation is active', () => {
13 | cy.get('button.active')
14 | .should('have.text', 'Home')
15 | })
16 |
17 | describe('route to page one', () => {
18 | before(() => {
19 | cy.get('button:nth-of-type(2)').click()
20 | })
21 |
22 | it('has rendered page one', () => {
23 | cy.get('#router-outlet')
24 | .should('contain.text', 'Welcome to page one!')
25 | })
26 |
27 | it('page one navigation is active', () => {
28 | cy.get('button.active')
29 | .should('have.text', 'Page one')
30 | })
31 | })
32 |
33 | describe('route to page two', () => {
34 | before(() => {
35 | cy.get('button:nth-of-type(3)').click()
36 | })
37 |
38 | it('has rendered page two', () => {
39 | cy.get('#router-outlet')
40 | .should('contain.text', 'Welcome to page two!')
41 | })
42 |
43 | it('page two navigation is active', () => {
44 | cy.get('button.active')
45 | .should('have.text', 'Page two')
46 | })
47 | })
48 |
49 | describe('route to home from page two content', () => {
50 | before(() => {
51 | cy.get('#router-outlet button').click()
52 | })
53 |
54 | it('has rendered the home page', () => {
55 | cy.get('#router-outlet')
56 | .should('contain.text', 'Welcome to the home page!')
57 | })
58 |
59 | it('home page navigation is active', () => {
60 | cy.get('button.active')
61 | .should('have.text', 'Home')
62 | })
63 | })
64 |
65 | describe('route to user page', () => {
66 | before(() => {
67 | cy.get('button:nth-of-type(4)').click()
68 | })
69 |
70 | it('has rendered the user page', () => {
71 | cy.get('#router-outlet')
72 | .should('contain.text', 'Welcome matt to the user page!')
73 | })
74 | })
75 |
76 | describe('route to task page', () => {
77 | before(() => {
78 | cy.get('button:nth-of-type(5)').click()
79 | })
80 |
81 | it('has rendered the user page with task information', () => {
82 | cy.get('#router-outlet')
83 | .should('contain.text', 'Task 12345')
84 | })
85 | })
86 | })
87 |
--------------------------------------------------------------------------------
/cypress/fixtures/example.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "Using fixtures to represent data",
3 | "email": "hello@cypress.io",
4 | "body": "Fixtures are a great way to mock data for responses to routes"
5 | }
--------------------------------------------------------------------------------
/cypress/plugins/index.cjs:
--------------------------------------------------------------------------------
1 | ///
2 | // ***********************************************************
3 | // This example plugins/index.js can be used to load plugins
4 | //
5 | // You can change the location of this file or turn off loading
6 | // the plugins file with the 'pluginsFile' configuration option.
7 | //
8 | // You can read more here:
9 | // https://on.cypress.io/plugins-guide
10 | // ***********************************************************
11 |
12 | // This function is called when a project is opened or re-opened (e.g. due to
13 | // the project's config changing)
14 |
15 | /**
16 | * @type {Cypress.PluginConfig}
17 | */
18 | module.exports = (on, config) => {
19 | // `on` is used to hook into various events Cypress emits
20 | // `config` is the resolved Cypress config
21 | }
22 |
--------------------------------------------------------------------------------
/cypress/support/commands.js:
--------------------------------------------------------------------------------
1 | // ***********************************************
2 | // This example commands.js shows you how to
3 | // create various custom commands and overwrite
4 | // existing commands.
5 | //
6 | // For more comprehensive examples of custom
7 | // commands please read more here:
8 | // https://on.cypress.io/custom-commands
9 | // ***********************************************
10 | //
11 | //
12 | // -- This is a parent command --
13 | // Cypress.Commands.add("login", (email, password) => { ... })
14 | //
15 | //
16 | // -- This is a child command --
17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... })
18 | //
19 | //
20 | // -- This is a dual command --
21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... })
22 | //
23 | //
24 | // -- This will overwrite an existing command --
25 | Cypress.Commands.overwrite('visit', (originalFn, url, options) => originalFn(`http://localhost:8080/test/e2e/${url}/`, options))
--------------------------------------------------------------------------------
/cypress/support/e2e.js:
--------------------------------------------------------------------------------
1 | // ***********************************************************
2 | // This example support/index.js is processed and
3 | // loaded automatically before your test files.
4 | //
5 | // This is a great place to put global configuration and
6 | // behavior that modifies Cypress.
7 | //
8 | // You can change the location of this file or turn off
9 | // automatically serving support files with the
10 | // 'supportFile' configuration option.
11 | //
12 | // You can read more here:
13 | // https://on.cypress.io/configuration
14 | // ***********************************************************
15 |
16 | // Import commands.js using ES2015 syntax:
17 | import './commands'
18 |
19 | // Alternatively you can use CommonJS syntax:
20 | // require('./commands')
21 |
--------------------------------------------------------------------------------
/dist/router.mjs:
--------------------------------------------------------------------------------
1 | const waitFor=(t=(()=>!0),e=1e4)=>new Promise(((r,o)=>{const check=()=>{const s=t();s?r(s):(e-=100)<0?o(new Error("Timed out waiting!")):setTimeout(check,100)};setTimeout(check,100)}));function isPromise(t){return("object"==typeof t||"function"==typeof t)&&"function"==typeof t.then}function hasKey(t,e){return Object.prototype.hasOwnProperty.call(t,e)}function elementEmpty(t){for(;t.firstChild;)t.removeChild(t.firstChild)}function renderOutlet(t,e){const r="string"==typeof e?document.querySelector(e):e;if(!r)return void console.warn(`Unable to find outlet: ${e}`);let o;if(function(t){return t instanceof Element||t instanceof HTMLElement}(t))o=t;else{o=window.customElements.get(t)?document.createElement(t):function(t){const e=document.createElement("div");return e.innerHTML=t.trim(),e.firstChild}(t)}elementEmpty(r),r.appendChild(o);const s="data-router-outlet";r.hasAttribute(s)||r.setAttribute(s,"")}let t=class extends Array{peek(){return this[this.length-1]}};function flatten(t,e,r,o=!0){r.forEach((r=>{if(r.children)!function(t,e,r){let o,s=[...r.children];if(r.action||r.component)o={...r},delete o.children;else{const t=s.find((t=>""===t.path));t&&(o={...t},o.path=r.path,delete o.children,s=s.filter((t=>""!==t.path)))}const n=copyRouteAndAdjustPath(o,e);t.push(n),e.push(n),flatten(t,e,s,!1)}(t,e,r);else{const o=copyRouteAndAdjustPath(r,e);t.push(o)}o&&e.splice(0,e.length)}))}function copyRouteAndAdjustPath(t,e){const r={...t},o=e.peek();return o&&(r.path=`${o.path}${r.path}`),r}function stripTrailingSlash(t){return"/"===t.charAt(t.length-1)?t.slice(0,-1):t}function addMatcherToRoute(t){const e={...t,matcher:e=>stripTrailingSlash(t.path)===stripTrailingSlash(e)?e:void 0};if(t.component&&!t.action&&(e.action=()=>t.component),/:[^/]+/.test(t.path)){const r=t.path.match(/(:[^/]+)/gm);r&&r.length>0&&(e.urlParamKeys=r.map((t=>t.substring(1))));const o=stripTrailingSlash(t.path.replace(/:[^/]+/g,"([^/]+)"));e.pathRegex=new RegExp(`^${o}$`),e.pathRegexCapture=new RegExp(o,"gm"),e.matcher=t=>{if(e.pathRegex.test(t)){const r={};let o;for(;null!==(o=e.pathRegexCapture.exec(t));)if(o&&o.length===e.urlParamKeys.length+1){const t=o.slice(1);for(let o=0;oe[0].toLowerCase()?1:t[0].toLowerCase()/g,">").replace(/&/g,"&")}_unesc(t){return String(t).replace(/</g,"<").replace(/'/g,"'").replace(/"/g,'"').replace(/>/g,">").replace(/&/g,"&")}}class r{constructor(){this.items=[]}push(t){this.items.push(t)}pop(){this.items.pop()}peek(){return this.items[this.items.length-1]}isEmpty(){return 0===this.items.length}clear(){this.items=[]}size(){return this.items.length}}class o{constructor(t,e,o){if("undefined"!=typeof window&&window.__ficusjs__&&window.__ficusjs__.router)return window.__ficusjs__.router;this._rootOutletSelector=e,this.stack=new r,this._routes=this._processRoutes(t),this._routerOptions=this._processOptions(o),this._outletCache=new WeakMap,"undefined"!=typeof window&&(window.addEventListener("popstate",(()=>{this._findAndRenderRoute(this.location).then((()=>this.stack.pop())).catch((t=>{throw this._renderError(this.location,t),t}))})),window.__ficusjs__=window.__ficusjs__||{},window.__ficusjs__.router=window.__ficusjs__.router||this)}get location(){let t;return"undefined"!=typeof window&&(t={host:window.location.host,protocol:window.location.protocol,pathname:window.location.pathname,hash:window.location.hash,href:window.location.href,search:window.location.search,state:window.history.state},"hash"===this._routerOptions.mode&&(t.pathname=this._getHashPathname(),t.hash="")),t}get options(){return this._routerOptions}setOptions(t={}){this._routerOptions=this._processOptions(t)}addRoutes(t){this._routes=[...this._routes,...this._processRoutes(t)]}hasRoute(t){return!!this._findRoute(t)}_getQueryStringParams(t){return Object.fromEntries(new e(t.href).entries())}_getUrlParams(t,e){const r=e.matcher(t.pathname);return"string"==typeof r?void 0:r}_findRoute(t){return this._routes.find((e=>void 0!==e.matcher(t)))}_getHashPathname(){let t=window.location.hash.substring(1);return""===t&&(t="/"),t}_processRoutes(e){return function(e){const r=new t;return flatten(r,new t,e),r}(e).map((t=>addMatcherToRoute(t)))}_processOptions(t={}){const e=this,r={mode:"history",autoStart:!0,changeHistoryState:!0,warnOnMissingOutlets:!1,...t};if(!r.resolveRoute)return{...r,resolveRoute(t,r){if(t.route&&"function"==typeof t.route.action)return{template:new Promise(((e,o)=>{e(t.route.action(t,r))})).catch((r=>{throw e._renderError(t.location,r),r})),outlets:t.route.outlets}}};if(r.resolveRoute){const t=r.resolveRoute;return{...r,resolveRoute:(e,r)=>t(e,r)}}return r}_render(t,e,r,o={}){return isPromise(r)?r.then((r=>this._performRender(t,e,r,o))):this._performRender(t,e,r,o)}_performRender(t,e,r,o){return"object"==typeof r&&r.redirect?Promise.resolve(r):r instanceof Error?(this._renderError(t,r),Promise.resolve(!0)):!1===r?Promise.resolve(!1):waitFor((()=>document.querySelector(this._rootOutletSelector))).then((()=>{const t=document.querySelector(this._rootOutletSelector),s=Object.keys(o),n=document.querySelectorAll("[data-router-outlet]:not([sticky])");return n.length&&[...n].filter((e=>e!==t)).forEach((t=>elementEmpty(t))),this._renderIntoOutlet(r,t),s.length?Promise.all(s.map((t=>waitFor((()=>{const e=document.querySelectorAll(t);return e.length>0&&e})).then((r=>{const s=o[t](e,e&&e.params);isPromise(s)?s.then((t=>this._renderIntoAllOutlets(t,r))):this._renderIntoAllOutlets(s,r)})).catch((t=>this._routerOptions.warnOnMissingOutlets&&console.warn(t)))))).then((()=>!0)):Promise.resolve(!0)}))}_renderIntoOutlet(t,e){t&&(this._isSameOutletContent(t,e)||(renderOutlet(t,e),this._outletCache.set(e,t),this._emitRouterOutletChangeEvent(e)))}_renderIntoAllOutlets(t,e){for(let r=0;r{if("object"==typeof t&&t.redirect){const e={from:o.href};return this.push(t.redirect,e)}return t&&this._routerOptions.changeHistoryState&&this._setState(o.href,e,r),Promise.resolve(t)})).catch((t=>{throw this._renderError(o,t),t}))}_normalizeLocation(t){const e={host:void 0,protocol:void 0,href:void 0,pathname:void 0,search:void 0,hash:void 0};if("object"==typeof t){if("history"===this._routerOptions.mode&&!t.pathname)throw new Error(`Unable to navigate to: ${t}`);let r=t.href||this.location.href;e.pathname=t.pathname||this.location.pathname,t.search&&""!==t.search&&!r.includes(t.search)&&(r=`${r}${t.search}`,e.search=t.search),t.hash&&""!==t.hash&&!r.includes(t.hash)&&(r=`${r}${t.hash}`,e.hash=t.hash),e.hash=t.hash||this.location.hash,e.search=t.search||this.location.search,e.host=t.host||this.location.host,e.protocol=t.protocol||this.location.protocol,e.href=r}else if("string"==typeof t){const r=new URL(/^https?:\/\//.test(t)?t:`${window.location.protocol}//${window.location.host}${t}`);e.host=r.host,e.protocol=r.protocol,e.href=r.href,e.pathname=r.pathname,e.search=r.search,e.hash=r.hash,t.includes("?")?(e.pathname=t.substring(0,t.indexOf("?")),e.search=t.substring(t.indexOf("?"))):t.includes("#")&&(e.pathname=t.substring(0,t.indexOf("#")))}return"hash"===this._routerOptions.mode&&(e.href=`${window.location.pathname}${e.search||""}#${e.pathname}`),e}_renderActionResult(t,e,r){if(hasKey(r,"template")&&hasKey(r,"outlets")){const{template:o,outlets:s}=r;return this._render(t,e,o,s)}return this._render(t,e,r,{})}_getRouteContext(t,e){let r={...this._getQueryStringParams(e)};const o={context:this._routerOptions.context,router:this,route:t,location:e,params:r};return t&&(r={...r,...this._getUrlParams(e,t)},o.params=r),o}_resolveRoute(t){const e=this._findRoute(t.pathname),r=this._getRouteContext(e,t);try{const t=this._routerOptions.resolveRoute(r,r.params);return null!=t?{actionResult:t,context:r}:e?{actionResult:e.action(r,r.params),context:r}:{actionResult:void 0,context:r}}catch(e){throw this._renderError(t,e),e}}_findAndRenderRoute(t){const{actionResult:e,context:r}=this._resolveRoute(t);if(!e)throw new Error("not_found");return this._renderActionResult(t,r,e)}_setState(t,e,r=!1){r?window.history.replaceState(e,null,t):(this.stack.push(t),window.history.pushState(e,null,t))}_renderError(t,e){if(console.error(`A router error occurred for location '${t.href}'`,e),this._routerOptions.errorHandler){const r={message:e.message,status:"not_found"===e.message?404:500},o=this._getRouteContext(this._findRoute(t.pathname),t),s=this._routerOptions.errorHandler(r,o);isPromise(s)?s.then((e=>this._render(t,o,e))).catch((r=>this._render(t,o,`
Router error from errorHandler: ${r.message}, original error: ${e.message}