@real-router/hash-plugin 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2025 Oleg Ivanov
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.
package/README.md ADDED
@@ -0,0 +1,210 @@
1
+ # @real-router/hash-plugin
2
+
3
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
4
+ [![TypeScript](https://img.shields.io/badge/TypeScript-5.9-blue.svg)](https://www.typescriptlang.org/)
5
+
6
+ Hash-based routing plugin for Real-Router. Uses URL hash fragment for navigation — no server configuration needed.
7
+
8
+ ## Installation
9
+
10
+ ```bash
11
+ npm install @real-router/hash-plugin
12
+ # or
13
+ pnpm add @real-router/hash-plugin
14
+ # or
15
+ yarn add @real-router/hash-plugin
16
+ # or
17
+ bun add @real-router/hash-plugin
18
+ ```
19
+
20
+ ## Quick Start
21
+
22
+ ```typescript
23
+ import { createRouter } from "@real-router/core";
24
+ import { hashPluginFactory } from "@real-router/hash-plugin";
25
+
26
+ const router = createRouter([
27
+ { name: "home", path: "/" },
28
+ { name: "products", path: "/products/:id" },
29
+ { name: "cart", path: "/cart" },
30
+ ]);
31
+
32
+ // Basic usage
33
+ router.usePlugin(hashPluginFactory());
34
+
35
+ // With options
36
+ router.usePlugin(
37
+ hashPluginFactory({
38
+ hashPrefix: "!",
39
+ }),
40
+ );
41
+
42
+ await router.start();
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Configuration
48
+
49
+ ```typescript
50
+ router.usePlugin(
51
+ hashPluginFactory({
52
+ hashPrefix: "!",
53
+ forceDeactivate: true,
54
+ }),
55
+ );
56
+
57
+ router.navigate("products", { id: "123" });
58
+ // URL: http://example.com/#!/products/123
59
+ ```
60
+
61
+ | Option | Type | Default | Description |
62
+ | ----------------- | --------- | ------- | ----------------------------------------------------- |
63
+ | `hashPrefix` | `string` | `""` | Prefix after `#` (e.g., `"!"` → `#!/path`) |
64
+ | `base` | `string` | `""` | Base path before hash (e.g., `"/app"` → `/app#/path`) |
65
+ | `forceDeactivate` | `boolean` | `true` | Bypass `canDeactivate` guards on browser back/forward |
66
+
67
+ > **Looking for History API routing?** Use [`@real-router/browser-plugin`](https://www.npmjs.com/package/@real-router/browser-plugin) instead.
68
+
69
+ See [Wiki](https://github.com/greydragon888/real-router/wiki/hash-plugin) for detailed option descriptions and examples.
70
+
71
+ ---
72
+
73
+ ## Added Router Methods
74
+
75
+ The plugin extends the router instance with browser-specific methods (via [`extendRouter()`](https://github.com/greydragon888/real-router/wiki/extendRouter)):
76
+
77
+ #### `router.buildUrl(name: string, params?: Params): string`
78
+
79
+ Build full URL with hash prefix.\
80
+ `name: string` — route name\
81
+ `params?: Params` — route parameters\
82
+ Returns: `string` — full URL\
83
+ [Wiki](https://github.com/greydragon888/real-router/wiki/hash-plugin#5-router-interaction)
84
+
85
+ ```typescript
86
+ router.buildUrl("users", { id: "123" });
87
+ // => "#!/users/123" (with hashPrefix "!")
88
+ ```
89
+
90
+ #### `router.matchUrl(url: string): State | undefined`
91
+
92
+ Parse URL to router state.\
93
+ `url: string` — URL to parse\
94
+ Returns: `State | undefined`\
95
+ [Wiki](https://github.com/greydragon888/real-router/wiki/hash-plugin#5-router-interaction)
96
+
97
+ ```typescript
98
+ const state = router.matchUrl("https://example.com/#!/users/123");
99
+ // => { name: "users", params: { id: "123" }, ... }
100
+ ```
101
+
102
+ #### `router.replaceHistoryState(name: string, params?: Params, title?: string): void`
103
+
104
+ Update browser URL without triggering navigation.\
105
+ `name: string` — route name\
106
+ `params?: Params` — route parameters\
107
+ `title?: string` — page title\
108
+ Returns: `void`\
109
+ [Wiki](https://github.com/greydragon888/real-router/wiki/hash-plugin#5-router-interaction)
110
+
111
+ ```typescript
112
+ router.replaceHistoryState("users", { id: "456" });
113
+ ```
114
+
115
+ ---
116
+
117
+ ## Usage Examples
118
+
119
+ ### Hashbang Routing
120
+
121
+ ```typescript
122
+ router.usePlugin(
123
+ hashPluginFactory({
124
+ hashPrefix: "!",
125
+ }),
126
+ );
127
+
128
+ router.navigate("users", { id: "123" });
129
+ // URL: #!/users/123
130
+ ```
131
+
132
+ ### With Base Path
133
+
134
+ ```typescript
135
+ router.usePlugin(
136
+ hashPluginFactory({
137
+ hashPrefix: "!",
138
+ base: "/app",
139
+ }),
140
+ );
141
+
142
+ router.navigate("users", { id: "123" });
143
+ // URL: /app#!/users/123
144
+ ```
145
+
146
+ ### Form Protection
147
+
148
+ ```typescript
149
+ router.usePlugin(
150
+ hashPluginFactory({
151
+ forceDeactivate: false,
152
+ }),
153
+ );
154
+
155
+ import { getLifecycleApi } from "@real-router/core";
156
+
157
+ const lifecycle = getLifecycleApi(router);
158
+ lifecycle.addDeactivateGuard("checkout", () => (toState, fromState) => {
159
+ return !hasUnsavedChanges(); // false blocks navigation
160
+ });
161
+ ```
162
+
163
+ ---
164
+
165
+ ## SSR Support
166
+
167
+ The plugin is SSR-safe with automatic fallback:
168
+
169
+ ```typescript
170
+ // Server-side — no errors, methods return safe defaults
171
+ router.usePlugin(hashPluginFactory());
172
+ router.buildUrl("home"); // Works
173
+ router.matchUrl("/path"); // Returns undefined
174
+ ```
175
+
176
+ ---
177
+
178
+ ## Why Hash Routing?
179
+
180
+ Hash-based routing stores the entire route in the URL hash fragment (`#/path`). This means:
181
+
182
+ - **No server configuration** — the server always serves the same `index.html` regardless of the URL
183
+ - **Works on static hosting** — GitHub Pages, S3, Netlify (without redirect rules)
184
+ - **Legacy browser support** — works everywhere that supports `hashchange` events
185
+
186
+ The tradeoff is less clean URLs (`example.com/#!/users` vs `example.com/users`).
187
+
188
+ ---
189
+
190
+ ## Documentation
191
+
192
+ Full documentation available on the [Wiki](https://github.com/greydragon888/real-router/wiki/hash-plugin):
193
+
194
+ - [Configuration Options](https://github.com/greydragon888/real-router/wiki/hash-plugin#3-configuration-options)
195
+ - [Lifecycle Hooks](https://github.com/greydragon888/real-router/wiki/hash-plugin#4-lifecycle-hooks)
196
+ - [Router Methods](https://github.com/greydragon888/real-router/wiki/hash-plugin#5-router-interaction)
197
+ - [Behavior & Edge Cases](https://github.com/greydragon888/real-router/wiki/hash-plugin#8-behavior)
198
+
199
+ ---
200
+
201
+ ## Related Packages
202
+
203
+ - [@real-router/core](https://www.npmjs.com/package/@real-router/core) — Core router
204
+ - [@real-router/browser-plugin](https://www.npmjs.com/package/@real-router/browser-plugin) — History API routing
205
+ - [@real-router/react](https://www.npmjs.com/package/@real-router/react) — React integration
206
+ - [@real-router/logger-plugin](https://www.npmjs.com/package/@real-router/logger-plugin) — Debug logging
207
+
208
+ ## License
209
+
210
+ MIT © [Oleg Ivanov](https://github.com/greydragon888)
@@ -0,0 +1,109 @@
1
+ import { State as State$1, PluginFactory, Params as Params$1 } from '@real-router/core';
2
+
3
+ /**
4
+ * Hash-based routing plugin configuration.
5
+ * Uses URL hash fragment for navigation (e.g., example.com/#/path).
6
+ */
7
+ interface HashPluginOptions {
8
+ /**
9
+ * Prefix for hash (e.g., "!" for "#!/path").
10
+ *
11
+ * @default ""
12
+ */
13
+ hashPrefix?: string;
14
+ /**
15
+ * Base path prepended before hash (e.g., "/app" → "/app#/path").
16
+ *
17
+ * @default ""
18
+ */
19
+ base?: string;
20
+ /**
21
+ * Force deactivation of current route even if canDeactivate returns false.
22
+ *
23
+ * @default true
24
+ */
25
+ forceDeactivate?: boolean;
26
+ }
27
+
28
+ interface HistoryBrowser {
29
+ pushState: (state: State$1, path: string) => void;
30
+ replaceState: (state: State$1, path: string) => void;
31
+ addPopstateListener: (fn: (evt: PopStateEvent) => void) => () => void;
32
+ getHash: () => string;
33
+ }
34
+ interface Browser extends HistoryBrowser {
35
+ getLocation: () => string;
36
+ }
37
+
38
+ declare function hashPluginFactory(opts?: Partial<HashPluginOptions>, browser?: Browser): PluginFactory;
39
+
40
+ type TransitionPhase = "deactivating" | "activating";
41
+ type TransitionReason = "success" | "blocked" | "cancelled" | "error";
42
+ interface TransitionMeta {
43
+ readonly reload?: boolean;
44
+ readonly redirected?: boolean;
45
+ phase: TransitionPhase;
46
+ from?: string;
47
+ reason: TransitionReason;
48
+ blocker?: string;
49
+ segments: {
50
+ deactivated: string[];
51
+ activated: string[];
52
+ intersection: string;
53
+ };
54
+ }
55
+ interface State<P extends Params = Params, MP extends Params = Params> {
56
+ name: string;
57
+ params: P;
58
+ path: string;
59
+ meta?: StateMeta<MP> | undefined;
60
+ transition?: TransitionMeta | undefined;
61
+ }
62
+ interface StateMeta<P extends Params = Params> {
63
+ id: number;
64
+ params: P;
65
+ }
66
+ interface Params {
67
+ [key: string]: string | string[] | number | number[] | boolean | boolean[] | Params | Params[] | Record<string, string | number | boolean> | null | undefined;
68
+ }
69
+
70
+ /**
71
+ * Enhanced type guard for State with deep validation.
72
+ * Checks not only presence but also types of all required fields.
73
+ * Validates params using isParams and meta structure if present.
74
+ *
75
+ * @param value - Value to check
76
+ * @returns true if value is a valid State object with correct types
77
+ *
78
+ * @example
79
+ * isStateStrict({ name: 'home', params: {}, path: '/', meta: { id: 1 } }); // true
80
+ * isStateStrict({ name: 'home', params: 'invalid', path: '/' }); // false
81
+ */
82
+ declare function isStateStrict<P extends Params = Params, MP extends Params = Params>(value: unknown): value is State<P, MP>;
83
+
84
+ /**
85
+ * Module augmentation for real-router.
86
+ * Extends Router interface with hash plugin methods.
87
+ */
88
+ declare module "@real-router/core" {
89
+ interface Router {
90
+ /**
91
+ * Builds full URL for a route with base path and hash prefix.
92
+ * Added by hash plugin.
93
+ */
94
+ buildUrl: (name: string, params?: Params$1) => string;
95
+ /**
96
+ * Matches URL and returns corresponding state.
97
+ * Added by hash plugin.
98
+ */
99
+ matchUrl: (url: string) => State$1 | undefined;
100
+ /**
101
+ * Replaces current history state without triggering navigation.
102
+ * Added by hash plugin.
103
+ */
104
+ replaceHistoryState: (name: string, params?: Params$1, title?: string) => void;
105
+ start(path?: string): Promise<State$1>;
106
+ }
107
+ }
108
+
109
+ export { type Browser, type HashPluginOptions, hashPluginFactory, isStateStrict as isState };
@@ -0,0 +1 @@
1
+ var e=require("@real-router/core"),t=/^[A-Z_a-z][\w-]*(?:\.[A-Z_a-z][\w-]*)*$/;function r(e,t=new WeakSet){if(null==e)return!0;const n=typeof e;if("string"===n||"boolean"===n)return!0;if("number"===n)return Number.isFinite(e);if("function"===n||"symbol"===n)return!1;if(Array.isArray(e))return!t.has(e)&&(t.add(e),e.every(e=>r(e,t)));if("object"===n){if(t.has(e))return!1;t.add(e);const n=Object.getPrototypeOf(e);return(null===n||n===Object.prototype)&&Object.values(e).every(e=>r(e,t))}return!1}function n(e){if(null==e)return!0;const t=typeof e;return"string"===t||"boolean"===t||"number"===t&&Number.isFinite(e)}function o(e){if("object"!=typeof e||null===e||Array.isArray(e))return!1;const t=Object.getPrototypeOf(e);if(null!==t&&t!==Object.prototype)return!1;let o=!1;for(const t in e){if(!Object.hasOwn(e,t))continue;const r=e[t];if(!n(r)){const e=typeof r;if("function"===e||"symbol"===e)return!1;o=!0;break}}return!o||r(e)}function a(e){if(null==e)return!0;const t=typeof e;return"string"===t||"boolean"===t||("number"===t?Number.isFinite(e):!!Array.isArray(e)&&e.every(e=>{const t=typeof e;return"string"===t||"boolean"===t||"number"===t&&Number.isFinite(e)}))}function i(e){if("object"!=typeof e||null===e)return!1;const r=e;return!!function(e){return function(e){return"string"==typeof e&&(""===e||!(e.length>1e4)&&(!!e.startsWith("@@")||t.test(e)))}(e.name)&&"string"==typeof e.path&&o(e.params)}(r)&&(void 0===r.meta||function(e){if("object"!=typeof e||null===e)return!1;const t=e;return!("params"in t&&!function(e){if("object"!=typeof e||null===e||Array.isArray(e))return!1;for(const t in e)if(Object.hasOwn(e,t)&&!a(e[t]))return!1;return!0}(t.params)||"id"in t&&"number"!=typeof t.id)}(r.meta))}var s=(e,t)=>{globalThis.history.pushState(e,"",t)},c=(e,t)=>{globalThis.history.replaceState(e,"",t)},u=e=>(globalThis.addEventListener("popstate",e),()=>{globalThis.removeEventListener("popstate",e)}),l=()=>globalThis.location.hash,p=()=>{},f=e=>{let t=!1;return r=>{t||(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${r}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),t=!0)}},h=e=>{const t=f(e);return{pushState:()=>{t("pushState")},replaceState:()=>{t("replaceState")},addPopstateListener:()=>(t("addPopstateListener"),p),getHash:()=>(t("getHash"),"")}};function d(e,t,r,n){const o={meta:e.meta,name:e.name,params:e.params,path:e.path};r?n.replaceState(o,t):n.pushState(o,t)}function m(t){let r=!1,n=null;async function o(a){if(r)return console.warn(`[${t.loggerContext}] Transition in progress, deferring popstate event`),void(n=a);r=!0;try{const e=function(e,t,r){if(i(e.state))return{name:e.state.name,params:e.state.params};const n=t.matchPath(r.getLocation());return n?{name:n.name,params:n.params}:void 0}(a,t.api,t.browser);e?await t.router.navigate(e.name,e.params,t.transitionOptions):await t.router.navigateToDefault({...t.transitionOptions,reload:!0,replace:!0})}catch(r){r instanceof e.RouterError||function(e){console.error(`[${t.loggerContext}] Critical error in onPopState`,e);try{const e=t.router.getState();if(e){const r=t.buildUrl(e.name,e.params);t.browser.replaceState(e,r)}}catch(e){console.error(`[${t.loggerContext}] Failed to recover from critical error`,e)}}(r)}finally{r=!1,function(){if(n){const e=n;n=null,console.warn(`[${t.loggerContext}] Processing deferred popstate event`),o(e)}}()}}return e=>{o(e)}}function b(e,t,r,n){return(o,a={})=>{const i=e.buildState(o,a);if(!i)throw new Error(`[real-router] Cannot replace state: route "${o}" is not found`);d(e.makeState(i.name,i.params,t.buildPath(i.name,i.params),{params:i.meta},1),n(o,a),!0,r)}}var g={hashPrefix:"",base:"",forceDeactivate:!0},v="hash-plugin",y=new Map;function w(e,t,r){const n=(e=>{const t=y.get(e);if(void 0!==t)return t;const r=e.replaceAll(/[$()*+.?[\\\]^{|}-]/g,String.raw`\$&`);return y.set(e,r),r})(t);return(n?e.replace(r.get(`^#${n}`),""):e.slice(1))||"/"}var S,P,$=class{#e;#t;#r;#n;#o;constructor(e,t,r,n,o,a,i){var s;this.#e=e,this.#t=n,this.#r=(s=n,t.addInterceptor("start",(e,t)=>e(t??s.getLocation())));const c=(t,n)=>{const o=e.buildPath(t,n);return`${r.base}#${r.hashPrefix}${o}`};this.#n=t.extendRouter({buildUrl:c,matchUrl:e=>{const n=function(e,t,r){const n=function(e,t){try{const r=new URL(e,globalThis.location.origin);return["http:","https:"].includes(r.protocol)?r:(console.warn(`[${t}] Invalid URL protocol in ${e}`),null)}catch(r){return console.warn(`[${t}] Could not parse url ${e}`,r),null}}(e,v);return n?w(n.hash,t,r)+n.search:null}(e,r.hashPrefix,o);return n?t.matchPath(n):void 0},replaceHistoryState:b(t,e,n,c)});const u=m({router:e,api:t,browser:n,transitionOptions:a,loggerContext:"hash-plugin",buildUrl:(t,r)=>e.buildUrl(t,r)});this.#o=function(e){return{onStart:()=>{e.shared.removePopStateListener&&e.shared.removePopStateListener(),e.shared.removePopStateListener=e.browser.addPopstateListener(e.handler)},onStop:()=>{e.shared.removePopStateListener&&(e.shared.removePopStateListener(),e.shared.removePopStateListener=void 0)},teardown:()=>{e.shared.removePopStateListener&&(e.shared.removePopStateListener(),e.shared.removePopStateListener=void 0),e.cleanup()}}}({browser:n,shared:i,handler:u,cleanup:()=>{this.#r(),this.#n()}})}getPlugin(){return{...this.#o,onTransitionSuccess:(e,t,r)=>{const n=(a=t,i=this.#e,((o=r).replace??!a)||!!o.reload&&i.areStatesEqual(e,a,!1));var o,a,i;d(e,this.#e.buildUrl(e.name,e.params),n,this.#t)}}}},L=(S=g,P=v,e=>{if(e)for(const t of Object.keys(e))if(t in S){const r=e[t],n=typeof S[t],o=typeof r;if(void 0!==r&&o!==n)throw new Error(`[${P}] Invalid type for '${t}': expected ${n}, got ${o}`)}});exports.hashPluginFactory=function(t,r){L(t);const n={...g,...t};n.base=function(e){if(!e)return e;let t=e;return t.startsWith("/")||(t=`/${t}`),t.endsWith("/")&&(t=t.slice(0,-1)),t}(n.base);const o=function(){const e=new Map;return{get(t){const r=e.get(t);if(void 0!==r)return r;const n=new RegExp(t);return e.set(t,n),n}}}(),a=r??function(e,t){if(void 0!==globalThis.window&&globalThis.history)return{pushState:s,replaceState:c,addPopstateListener:u,getLocation:e,getHash:l};const r=f(t);return{...h(t),getLocation:()=>(r("getLocation"),"")}}(()=>(e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}})(w(globalThis.location.hash,n.hashPrefix,o))+globalThis.location.search,"hash-plugin"),i={forceDeactivate:n.forceDeactivate,source:"popstate",replace:!0},p={removePopStateListener:void 0};return function(t){return new $(t,e.getPluginApi(t),n,a,o,i,p).getPlugin()}},exports.isState=i;//# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/constants.ts","../../src/hash-utils.ts","../../src/plugin.ts","../../src/validation.ts","../../src/factory.ts"],"names":["i","f","c","getPluginApi"],"mappings":"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","file":"index.js","sourcesContent":["// packages/hash-plugin/src/constants.ts\n\nimport type { HashPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<HashPluginOptions> = {\n hashPrefix: \"\",\n base: \"\",\n forceDeactivate: true,\n};\n\n/**\n * Source identifier for transitions triggered by browser events.\n */\nexport const source = \"popstate\";\n\nexport const LOGGER_CONTEXT = \"hash-plugin\";\n","// packages/hash-plugin/src/hash-utils.ts\n\nimport { safeParseUrl } from \"browser-env\";\n\nimport { LOGGER_CONTEXT } from \"./constants\";\n\nexport interface RegExpCache {\n get: (pattern: string) => RegExp;\n}\n\nconst escapeRegExpCache = new Map<string, string>();\n\nexport const escapeRegExp = (str: string): string => {\n const cached = escapeRegExpCache.get(str);\n\n if (cached !== undefined) {\n return cached;\n }\n\n const escaped = str.replaceAll(/[$()*+.?[\\\\\\]^{|}-]/g, String.raw`\\$&`);\n\n escapeRegExpCache.set(str, escaped);\n\n return escaped;\n};\n\nexport function createRegExpCache(): RegExpCache {\n const cache = new Map<string, RegExp>();\n\n return {\n get(pattern: string): RegExp {\n const cached = cache.get(pattern);\n\n if (cached !== undefined) {\n return cached;\n }\n\n const newRegExp = new RegExp(pattern);\n\n cache.set(pattern, newRegExp);\n\n return newRegExp;\n },\n };\n}\n\n/**\n * Extract path from URL hash, stripping hash prefix.\n *\n * @param hash - URL hash (e.g., \"#/path\" or \"#!/path\")\n * @param hashPrefix - Hash prefix to strip (e.g., \"!\")\n * @param regExpCache - RegExp cache for compiled patterns\n * @returns Extracted path (e.g., \"/path\")\n */\nexport function extractHashPath(\n hash: string,\n hashPrefix: string,\n regExpCache: RegExpCache,\n): string {\n const escapedHashPrefix = escapeRegExp(hashPrefix);\n const path = escapedHashPrefix\n ? hash.replace(regExpCache.get(`^#${escapedHashPrefix}`), \"\")\n : hash.slice(1);\n\n return path || \"/\";\n}\n\nexport function hashUrlToPath(\n url: string,\n hashPrefix: string,\n regExpCache: RegExpCache,\n): string | null {\n const parsedUrl = safeParseUrl(url, LOGGER_CONTEXT);\n\n return parsedUrl\n ? extractHashPath(parsedUrl.hash, hashPrefix, regExpCache) +\n parsedUrl.search\n : null;\n}\n","import {\n createPopstateHandler,\n createPopstateLifecycle,\n createStartInterceptor,\n createReplaceHistoryState,\n shouldReplaceHistory,\n updateBrowserState,\n} from \"browser-env\";\n\nimport { hashUrlToPath } from \"./hash-utils\";\n\nimport type { RegExpCache } from \"./hash-utils\";\nimport type { HashPluginOptions } from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n PluginApi,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { Browser, SharedFactoryState } from \"browser-env\";\n\nexport class HashPlugin {\n readonly #router: Router;\n readonly #browser: Browser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<HashPluginOptions>,\n browser: Browser,\n regExpCache: RegExpCache,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: SharedFactoryState,\n ) {\n this.#router = router;\n this.#browser = browser;\n\n this.#removeStartInterceptor = createStartInterceptor(api, browser);\n\n const pluginBuildUrl = (route: string, params?: Params) => {\n const path = router.buildPath(route, params);\n\n return `${options.base}#${options.hashPrefix}${path}`;\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) => {\n const path = hashUrlToPath(url, options.hashPrefix, regExpCache);\n\n return path ? api.matchPath(path) : undefined;\n },\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n browser,\n pluginBuildUrl,\n ),\n });\n\n const handler = createPopstateHandler({\n router,\n api,\n browser,\n transitionOptions,\n loggerContext: \"hash-plugin\",\n buildUrl: (name: string, params?: Params) =>\n router.buildUrl(name, params),\n });\n\n this.#lifecycle = createPopstateLifecycle({\n browser,\n shared,\n handler,\n cleanup: () => {\n this.#removeStartInterceptor();\n this.#removeExtensions();\n },\n });\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n const replaceHistory = shouldReplaceHistory(\n navOptions,\n toState,\n fromState,\n this.#router,\n );\n\n const url = this.#router.buildUrl(toState.name, toState.params);\n\n updateBrowserState(toState, url, replaceHistory, this.#browser);\n },\n };\n }\n}\n","import { createOptionsValidator } from \"browser-env\";\n\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { HashPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<HashPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n);\n","import { getPluginApi } from \"@real-router/core\";\nimport {\n createSafeBrowser,\n normalizeBase,\n safelyEncodePath,\n} from \"browser-env\";\n\nimport { defaultOptions, source } from \"./constants\";\nimport { createRegExpCache, extractHashPath } from \"./hash-utils\";\nimport { HashPlugin } from \"./plugin\";\nimport { validateOptions } from \"./validation\";\n\nimport type { HashPluginOptions } from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\nimport type { Browser, SharedFactoryState } from \"browser-env\";\n\nexport function hashPluginFactory(\n opts?: Partial<HashPluginOptions>,\n browser?: Browser,\n): PluginFactory {\n validateOptions(opts);\n\n const options: Required<HashPluginOptions> = { ...defaultOptions, ...opts };\n\n options.base = normalizeBase(options.base);\n\n const regExpCache = createRegExpCache();\n const resolvedBrowser =\n browser ??\n createSafeBrowser(\n () =>\n safelyEncodePath(\n extractHashPath(\n globalThis.location.hash,\n options.hashPrefix,\n regExpCache,\n ),\n ) + globalThis.location.search,\n \"hash-plugin\",\n );\n\n const transitionOptions = {\n forceDeactivate: options.forceDeactivate,\n source,\n replace: true as const,\n };\n\n const shared: SharedFactoryState = { removePopStateListener: undefined };\n\n return function hashPlugin(routerBase) {\n const plugin = new HashPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n resolvedBrowser,\n regExpCache,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n"]}
@@ -0,0 +1 @@
1
+ {"inputs":{"../../node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js":{"bytes":569,"imports":[],"format":"esm"},"../type-guards/dist/esm/index.mjs":{"bytes":3451,"imports":[{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"../browser-env/dist/esm/index.mjs":{"bytes":4084,"imports":[{"path":"../type-guards/dist/esm/index.mjs","kind":"import-statement","original":"type-guards"},{"path":"@real-router/core","kind":"import-statement","external":true},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/constants.ts":{"bytes":367,"imports":[{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/hash-utils.ts":{"bytes":1808,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/plugin.ts":{"bytes":2749,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/hash-utils.ts","kind":"import-statement","original":"./hash-utils"},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/validation.ts":{"bytes":282,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/factory.ts":{"bytes":1621,"imports":[{"path":"@real-router/core","kind":"import-statement","external":true},{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"},{"path":"src/hash-utils.ts","kind":"import-statement","original":"./hash-utils"},{"path":"src/plugin.ts","kind":"import-statement","original":"./plugin"},{"path":"src/validation.ts","kind":"import-statement","original":"./validation"},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"},"src/index.ts":{"bytes":1283,"imports":[{"path":"src/factory.ts","kind":"import-statement","original":"./factory"},{"path":"../type-guards/dist/esm/index.mjs","kind":"import-statement","original":"type-guards"},{"path":"/Users/olegivanov/WebstormProjects/real-router/node_modules/.pnpm/tsup@8.5.1_jiti@2.6.1_postcss@8.5.6_typescript@5.9.3/node_modules/tsup/assets/cjs_shims.js","kind":"import-statement","external":true}],"format":"esm"}},"outputs":{"dist/cjs/index.js.map":{"imports":[],"exports":[],"inputs":{},"bytes":10268},"dist/cjs/index.js":{"imports":[{"path":"@real-router/core","kind":"import-statement","external":true},{"path":"@real-router/core","kind":"import-statement","external":true}],"exports":["hashPluginFactory","isState"],"entryPoint":"src/index.ts","inputs":{"src/factory.ts":{"bytesInOutput":906},"../type-guards/dist/esm/index.mjs":{"bytesInOutput":2337},"../browser-env/dist/esm/index.mjs":{"bytesInOutput":4688},"src/constants.ts":{"bytesInOutput":141},"src/hash-utils.ts":{"bytesInOutput":1091},"src/plugin.ts":{"bytesInOutput":1615},"src/validation.ts":{"bytesInOutput":63},"src/index.ts":{"bytesInOutput":0}},"bytes":11085}}}
@@ -0,0 +1,109 @@
1
+ import { State as State$1, PluginFactory, Params as Params$1 } from '@real-router/core';
2
+
3
+ /**
4
+ * Hash-based routing plugin configuration.
5
+ * Uses URL hash fragment for navigation (e.g., example.com/#/path).
6
+ */
7
+ interface HashPluginOptions {
8
+ /**
9
+ * Prefix for hash (e.g., "!" for "#!/path").
10
+ *
11
+ * @default ""
12
+ */
13
+ hashPrefix?: string;
14
+ /**
15
+ * Base path prepended before hash (e.g., "/app" → "/app#/path").
16
+ *
17
+ * @default ""
18
+ */
19
+ base?: string;
20
+ /**
21
+ * Force deactivation of current route even if canDeactivate returns false.
22
+ *
23
+ * @default true
24
+ */
25
+ forceDeactivate?: boolean;
26
+ }
27
+
28
+ interface HistoryBrowser {
29
+ pushState: (state: State$1, path: string) => void;
30
+ replaceState: (state: State$1, path: string) => void;
31
+ addPopstateListener: (fn: (evt: PopStateEvent) => void) => () => void;
32
+ getHash: () => string;
33
+ }
34
+ interface Browser extends HistoryBrowser {
35
+ getLocation: () => string;
36
+ }
37
+
38
+ declare function hashPluginFactory(opts?: Partial<HashPluginOptions>, browser?: Browser): PluginFactory;
39
+
40
+ type TransitionPhase = "deactivating" | "activating";
41
+ type TransitionReason = "success" | "blocked" | "cancelled" | "error";
42
+ interface TransitionMeta {
43
+ readonly reload?: boolean;
44
+ readonly redirected?: boolean;
45
+ phase: TransitionPhase;
46
+ from?: string;
47
+ reason: TransitionReason;
48
+ blocker?: string;
49
+ segments: {
50
+ deactivated: string[];
51
+ activated: string[];
52
+ intersection: string;
53
+ };
54
+ }
55
+ interface State<P extends Params = Params, MP extends Params = Params> {
56
+ name: string;
57
+ params: P;
58
+ path: string;
59
+ meta?: StateMeta<MP> | undefined;
60
+ transition?: TransitionMeta | undefined;
61
+ }
62
+ interface StateMeta<P extends Params = Params> {
63
+ id: number;
64
+ params: P;
65
+ }
66
+ interface Params {
67
+ [key: string]: string | string[] | number | number[] | boolean | boolean[] | Params | Params[] | Record<string, string | number | boolean> | null | undefined;
68
+ }
69
+
70
+ /**
71
+ * Enhanced type guard for State with deep validation.
72
+ * Checks not only presence but also types of all required fields.
73
+ * Validates params using isParams and meta structure if present.
74
+ *
75
+ * @param value - Value to check
76
+ * @returns true if value is a valid State object with correct types
77
+ *
78
+ * @example
79
+ * isStateStrict({ name: 'home', params: {}, path: '/', meta: { id: 1 } }); // true
80
+ * isStateStrict({ name: 'home', params: 'invalid', path: '/' }); // false
81
+ */
82
+ declare function isStateStrict<P extends Params = Params, MP extends Params = Params>(value: unknown): value is State<P, MP>;
83
+
84
+ /**
85
+ * Module augmentation for real-router.
86
+ * Extends Router interface with hash plugin methods.
87
+ */
88
+ declare module "@real-router/core" {
89
+ interface Router {
90
+ /**
91
+ * Builds full URL for a route with base path and hash prefix.
92
+ * Added by hash plugin.
93
+ */
94
+ buildUrl: (name: string, params?: Params$1) => string;
95
+ /**
96
+ * Matches URL and returns corresponding state.
97
+ * Added by hash plugin.
98
+ */
99
+ matchUrl: (url: string) => State$1 | undefined;
100
+ /**
101
+ * Replaces current history state without triggering navigation.
102
+ * Added by hash plugin.
103
+ */
104
+ replaceHistoryState: (name: string, params?: Params$1, title?: string) => void;
105
+ start(path?: string): Promise<State$1>;
106
+ }
107
+ }
108
+
109
+ export { type Browser, type HashPluginOptions, hashPluginFactory, isStateStrict as isState };
@@ -0,0 +1 @@
1
+ import{getPluginApi as e,RouterError as t}from"@real-router/core";var r=/^[A-Z_a-z][\w-]*(?:\.[A-Z_a-z][\w-]*)*$/;function n(e,t=new WeakSet){if(null==e)return!0;const r=typeof e;if("string"===r||"boolean"===r)return!0;if("number"===r)return Number.isFinite(e);if("function"===r||"symbol"===r)return!1;if(Array.isArray(e))return!t.has(e)&&(t.add(e),e.every(e=>n(e,t)));if("object"===r){if(t.has(e))return!1;t.add(e);const r=Object.getPrototypeOf(e);return(null===r||r===Object.prototype)&&Object.values(e).every(e=>n(e,t))}return!1}function o(e){if(null==e)return!0;const t=typeof e;return"string"===t||"boolean"===t||"number"===t&&Number.isFinite(e)}function a(e){if("object"!=typeof e||null===e||Array.isArray(e))return!1;const t=Object.getPrototypeOf(e);if(null!==t&&t!==Object.prototype)return!1;let r=!1;for(const t in e){if(!Object.hasOwn(e,t))continue;const n=e[t];if(!o(n)){const e=typeof n;if("function"===e||"symbol"===e)return!1;r=!0;break}}return!r||n(e)}function i(e){if(null==e)return!0;const t=typeof e;return"string"===t||"boolean"===t||("number"===t?Number.isFinite(e):!!Array.isArray(e)&&e.every(e=>{const t=typeof e;return"string"===t||"boolean"===t||"number"===t&&Number.isFinite(e)}))}function s(e){if("object"!=typeof e||null===e)return!1;const t=e;return!!function(e){return function(e){return"string"==typeof e&&(""===e||!(e.length>1e4)&&(!!e.startsWith("@@")||r.test(e)))}(e.name)&&"string"==typeof e.path&&a(e.params)}(t)&&(void 0===t.meta||function(e){if("object"!=typeof e||null===e)return!1;const t=e;return!("params"in t&&!function(e){if("object"!=typeof e||null===e||Array.isArray(e))return!1;for(const t in e)if(Object.hasOwn(e,t)&&!i(e[t]))return!1;return!0}(t.params)||"id"in t&&"number"!=typeof t.id)}(t.meta))}var c=(e,t)=>{globalThis.history.pushState(e,"",t)},u=(e,t)=>{globalThis.history.replaceState(e,"",t)},l=e=>(globalThis.addEventListener("popstate",e),()=>{globalThis.removeEventListener("popstate",e)}),p=()=>globalThis.location.hash,f=()=>{},h=e=>{let t=!1;return r=>{t||(console.warn(`[browser-env] Browser API is running in a non-browser environment (context: "${e}"). Method "${r}" is a no-op. This is expected for SSR, but may indicate misconfiguration if you expected browser behavior.`),t=!0)}},d=e=>{const t=h(e);return{pushState:()=>{t("pushState")},replaceState:()=>{t("replaceState")},addPopstateListener:()=>(t("addPopstateListener"),f),getHash:()=>(t("getHash"),"")}};function m(e,t,r,n){const o={meta:e.meta,name:e.name,params:e.params,path:e.path};r?n.replaceState(o,t):n.pushState(o,t)}function b(e){let r=!1,n=null;async function o(a){if(r)return console.warn(`[${e.loggerContext}] Transition in progress, deferring popstate event`),void(n=a);r=!0;try{const t=function(e,t,r){if(s(e.state))return{name:e.state.name,params:e.state.params};const n=t.matchPath(r.getLocation());return n?{name:n.name,params:n.params}:void 0}(a,e.api,e.browser);t?await e.router.navigate(t.name,t.params,e.transitionOptions):await e.router.navigateToDefault({...e.transitionOptions,reload:!0,replace:!0})}catch(r){r instanceof t||function(t){console.error(`[${e.loggerContext}] Critical error in onPopState`,t);try{const t=e.router.getState();if(t){const r=e.buildUrl(t.name,t.params);e.browser.replaceState(t,r)}}catch(t){console.error(`[${e.loggerContext}] Failed to recover from critical error`,t)}}(r)}finally{r=!1,function(){if(n){const t=n;n=null,console.warn(`[${e.loggerContext}] Processing deferred popstate event`),o(t)}}()}}return e=>{o(e)}}function g(e,t,r,n){return(o,a={})=>{const i=e.buildState(o,a);if(!i)throw new Error(`[real-router] Cannot replace state: route "${o}" is not found`);m(e.makeState(i.name,i.params,t.buildPath(i.name,i.params),{params:i.meta},1),n(o,a),!0,r)}}var v={hashPrefix:"",base:"",forceDeactivate:!0},y="hash-plugin",w=new Map;function S(e,t,r){const n=(e=>{const t=w.get(e);if(void 0!==t)return t;const r=e.replaceAll(/[$()*+.?[\\\]^{|}-]/g,String.raw`\$&`);return w.set(e,r),r})(t);return(n?e.replace(r.get(`^#${n}`),""):e.slice(1))||"/"}var P,$,L=class{#e;#t;#r;#n;#o;constructor(e,t,r,n,o,a,i){var s;this.#e=e,this.#t=n,this.#r=(s=n,t.addInterceptor("start",(e,t)=>e(t??s.getLocation())));const c=(t,n)=>{const o=e.buildPath(t,n);return`${r.base}#${r.hashPrefix}${o}`};this.#n=t.extendRouter({buildUrl:c,matchUrl:e=>{const n=function(e,t,r){const n=function(e,t){try{const r=new URL(e,globalThis.location.origin);return["http:","https:"].includes(r.protocol)?r:(console.warn(`[${t}] Invalid URL protocol in ${e}`),null)}catch(r){return console.warn(`[${t}] Could not parse url ${e}`,r),null}}(e,y);return n?S(n.hash,t,r)+n.search:null}(e,r.hashPrefix,o);return n?t.matchPath(n):void 0},replaceHistoryState:g(t,e,n,c)});const u=b({router:e,api:t,browser:n,transitionOptions:a,loggerContext:"hash-plugin",buildUrl:(t,r)=>e.buildUrl(t,r)});this.#o=function(e){return{onStart:()=>{e.shared.removePopStateListener&&e.shared.removePopStateListener(),e.shared.removePopStateListener=e.browser.addPopstateListener(e.handler)},onStop:()=>{e.shared.removePopStateListener&&(e.shared.removePopStateListener(),e.shared.removePopStateListener=void 0)},teardown:()=>{e.shared.removePopStateListener&&(e.shared.removePopStateListener(),e.shared.removePopStateListener=void 0),e.cleanup()}}}({browser:n,shared:i,handler:u,cleanup:()=>{this.#r(),this.#n()}})}getPlugin(){return{...this.#o,onTransitionSuccess:(e,t,r)=>{const n=(a=t,i=this.#e,((o=r).replace??!a)||!!o.reload&&i.areStatesEqual(e,a,!1));var o,a,i;m(e,this.#e.buildUrl(e.name,e.params),n,this.#t)}}}},x=(P=v,$=y,e=>{if(e)for(const t of Object.keys(e))if(t in P){const r=e[t],n=typeof P[t],o=typeof r;if(void 0!==r&&o!==n)throw new Error(`[${$}] Invalid type for '${t}': expected ${n}, got ${o}`)}});function O(t,r){x(t);const n={...v,...t};n.base=function(e){if(!e)return e;let t=e;return t.startsWith("/")||(t=`/${t}`),t.endsWith("/")&&(t=t.slice(0,-1)),t}(n.base);const o=function(){const e=new Map;return{get(t){const r=e.get(t);if(void 0!==r)return r;const n=new RegExp(t);return e.set(t,n),n}}}(),a=r??function(e,t){if(void 0!==globalThis.window&&globalThis.history)return{pushState:c,replaceState:u,addPopstateListener:l,getLocation:e,getHash:p};const r=h(t);return{...d(t),getLocation:()=>(r("getLocation"),"")}}(()=>(e=>{try{return encodeURI(decodeURI(e))}catch(t){return console.warn(`[browser-env] Could not encode path "${e}"`,t),e}})(S(globalThis.location.hash,n.hashPrefix,o))+globalThis.location.search,"hash-plugin"),i={forceDeactivate:n.forceDeactivate,source:"popstate",replace:!0},s={removePopStateListener:void 0};return function(t){return new L(t,e(t),n,a,o,i,s).getPlugin()}}export{O as hashPluginFactory,s as isState};//# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/constants.ts","../../src/hash-utils.ts","../../src/plugin.ts","../../src/validation.ts","../../src/factory.ts"],"names":["i","f","c"],"mappings":"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","file":"index.mjs","sourcesContent":["// packages/hash-plugin/src/constants.ts\n\nimport type { HashPluginOptions } from \"./types\";\n\nexport const defaultOptions: Required<HashPluginOptions> = {\n hashPrefix: \"\",\n base: \"\",\n forceDeactivate: true,\n};\n\n/**\n * Source identifier for transitions triggered by browser events.\n */\nexport const source = \"popstate\";\n\nexport const LOGGER_CONTEXT = \"hash-plugin\";\n","// packages/hash-plugin/src/hash-utils.ts\n\nimport { safeParseUrl } from \"browser-env\";\n\nimport { LOGGER_CONTEXT } from \"./constants\";\n\nexport interface RegExpCache {\n get: (pattern: string) => RegExp;\n}\n\nconst escapeRegExpCache = new Map<string, string>();\n\nexport const escapeRegExp = (str: string): string => {\n const cached = escapeRegExpCache.get(str);\n\n if (cached !== undefined) {\n return cached;\n }\n\n const escaped = str.replaceAll(/[$()*+.?[\\\\\\]^{|}-]/g, String.raw`\\$&`);\n\n escapeRegExpCache.set(str, escaped);\n\n return escaped;\n};\n\nexport function createRegExpCache(): RegExpCache {\n const cache = new Map<string, RegExp>();\n\n return {\n get(pattern: string): RegExp {\n const cached = cache.get(pattern);\n\n if (cached !== undefined) {\n return cached;\n }\n\n const newRegExp = new RegExp(pattern);\n\n cache.set(pattern, newRegExp);\n\n return newRegExp;\n },\n };\n}\n\n/**\n * Extract path from URL hash, stripping hash prefix.\n *\n * @param hash - URL hash (e.g., \"#/path\" or \"#!/path\")\n * @param hashPrefix - Hash prefix to strip (e.g., \"!\")\n * @param regExpCache - RegExp cache for compiled patterns\n * @returns Extracted path (e.g., \"/path\")\n */\nexport function extractHashPath(\n hash: string,\n hashPrefix: string,\n regExpCache: RegExpCache,\n): string {\n const escapedHashPrefix = escapeRegExp(hashPrefix);\n const path = escapedHashPrefix\n ? hash.replace(regExpCache.get(`^#${escapedHashPrefix}`), \"\")\n : hash.slice(1);\n\n return path || \"/\";\n}\n\nexport function hashUrlToPath(\n url: string,\n hashPrefix: string,\n regExpCache: RegExpCache,\n): string | null {\n const parsedUrl = safeParseUrl(url, LOGGER_CONTEXT);\n\n return parsedUrl\n ? extractHashPath(parsedUrl.hash, hashPrefix, regExpCache) +\n parsedUrl.search\n : null;\n}\n","import {\n createPopstateHandler,\n createPopstateLifecycle,\n createStartInterceptor,\n createReplaceHistoryState,\n shouldReplaceHistory,\n updateBrowserState,\n} from \"browser-env\";\n\nimport { hashUrlToPath } from \"./hash-utils\";\n\nimport type { RegExpCache } from \"./hash-utils\";\nimport type { HashPluginOptions } from \"./types\";\nimport type {\n NavigationOptions,\n Params,\n PluginApi,\n Router,\n State,\n Plugin,\n} from \"@real-router/core\";\nimport type { Browser, SharedFactoryState } from \"browser-env\";\n\nexport class HashPlugin {\n readonly #router: Router;\n readonly #browser: Browser;\n readonly #removeStartInterceptor: () => void;\n readonly #removeExtensions: () => void;\n readonly #lifecycle: Pick<Plugin, \"onStart\" | \"onStop\" | \"teardown\">;\n\n constructor(\n router: Router,\n api: PluginApi,\n options: Required<HashPluginOptions>,\n browser: Browser,\n regExpCache: RegExpCache,\n transitionOptions: {\n source: string;\n replace: true;\n forceDeactivate?: boolean;\n },\n shared: SharedFactoryState,\n ) {\n this.#router = router;\n this.#browser = browser;\n\n this.#removeStartInterceptor = createStartInterceptor(api, browser);\n\n const pluginBuildUrl = (route: string, params?: Params) => {\n const path = router.buildPath(route, params);\n\n return `${options.base}#${options.hashPrefix}${path}`;\n };\n\n this.#removeExtensions = api.extendRouter({\n buildUrl: pluginBuildUrl,\n matchUrl: (url: string) => {\n const path = hashUrlToPath(url, options.hashPrefix, regExpCache);\n\n return path ? api.matchPath(path) : undefined;\n },\n replaceHistoryState: createReplaceHistoryState(\n api,\n router,\n browser,\n pluginBuildUrl,\n ),\n });\n\n const handler = createPopstateHandler({\n router,\n api,\n browser,\n transitionOptions,\n loggerContext: \"hash-plugin\",\n buildUrl: (name: string, params?: Params) =>\n router.buildUrl(name, params),\n });\n\n this.#lifecycle = createPopstateLifecycle({\n browser,\n shared,\n handler,\n cleanup: () => {\n this.#removeStartInterceptor();\n this.#removeExtensions();\n },\n });\n }\n\n getPlugin(): Plugin {\n return {\n ...this.#lifecycle,\n\n onTransitionSuccess: (\n toState: State,\n fromState: State | undefined,\n navOptions: NavigationOptions,\n ) => {\n const replaceHistory = shouldReplaceHistory(\n navOptions,\n toState,\n fromState,\n this.#router,\n );\n\n const url = this.#router.buildUrl(toState.name, toState.params);\n\n updateBrowserState(toState, url, replaceHistory, this.#browser);\n },\n };\n }\n}\n","import { createOptionsValidator } from \"browser-env\";\n\nimport { LOGGER_CONTEXT, defaultOptions } from \"./constants\";\n\nimport type { HashPluginOptions } from \"./types\";\n\nexport const validateOptions = createOptionsValidator<HashPluginOptions>(\n defaultOptions,\n LOGGER_CONTEXT,\n);\n","import { getPluginApi } from \"@real-router/core\";\nimport {\n createSafeBrowser,\n normalizeBase,\n safelyEncodePath,\n} from \"browser-env\";\n\nimport { defaultOptions, source } from \"./constants\";\nimport { createRegExpCache, extractHashPath } from \"./hash-utils\";\nimport { HashPlugin } from \"./plugin\";\nimport { validateOptions } from \"./validation\";\n\nimport type { HashPluginOptions } from \"./types\";\nimport type { PluginFactory, Router } from \"@real-router/core\";\nimport type { Browser, SharedFactoryState } from \"browser-env\";\n\nexport function hashPluginFactory(\n opts?: Partial<HashPluginOptions>,\n browser?: Browser,\n): PluginFactory {\n validateOptions(opts);\n\n const options: Required<HashPluginOptions> = { ...defaultOptions, ...opts };\n\n options.base = normalizeBase(options.base);\n\n const regExpCache = createRegExpCache();\n const resolvedBrowser =\n browser ??\n createSafeBrowser(\n () =>\n safelyEncodePath(\n extractHashPath(\n globalThis.location.hash,\n options.hashPrefix,\n regExpCache,\n ),\n ) + globalThis.location.search,\n \"hash-plugin\",\n );\n\n const transitionOptions = {\n forceDeactivate: options.forceDeactivate,\n source,\n replace: true as const,\n };\n\n const shared: SharedFactoryState = { removePopStateListener: undefined };\n\n return function hashPlugin(routerBase) {\n const plugin = new HashPlugin(\n routerBase as Router,\n getPluginApi(routerBase),\n options,\n resolvedBrowser,\n regExpCache,\n transitionOptions,\n shared,\n );\n\n return plugin.getPlugin();\n };\n}\n"]}
@@ -0,0 +1 @@
1
+ {"inputs":{"../type-guards/dist/esm/index.mjs":{"bytes":3451,"imports":[],"format":"esm"},"../browser-env/dist/esm/index.mjs":{"bytes":4084,"imports":[{"path":"../type-guards/dist/esm/index.mjs","kind":"import-statement","original":"type-guards"},{"path":"@real-router/core","kind":"import-statement","external":true}],"format":"esm"},"src/constants.ts":{"bytes":367,"imports":[],"format":"esm"},"src/hash-utils.ts":{"bytes":1808,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"}],"format":"esm"},"src/plugin.ts":{"bytes":2749,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/hash-utils.ts","kind":"import-statement","original":"./hash-utils"}],"format":"esm"},"src/validation.ts":{"bytes":282,"imports":[{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"}],"format":"esm"},"src/factory.ts":{"bytes":1621,"imports":[{"path":"@real-router/core","kind":"import-statement","external":true},{"path":"../browser-env/dist/esm/index.mjs","kind":"import-statement","original":"browser-env"},{"path":"src/constants.ts","kind":"import-statement","original":"./constants"},{"path":"src/hash-utils.ts","kind":"import-statement","original":"./hash-utils"},{"path":"src/plugin.ts","kind":"import-statement","original":"./plugin"},{"path":"src/validation.ts","kind":"import-statement","original":"./validation"}],"format":"esm"},"src/index.ts":{"bytes":1283,"imports":[{"path":"src/factory.ts","kind":"import-statement","original":"./factory"},{"path":"../type-guards/dist/esm/index.mjs","kind":"import-statement","original":"type-guards"}],"format":"esm"}},"outputs":{"dist/esm/index.mjs.map":{"imports":[],"exports":[],"inputs":{},"bytes":10268},"dist/esm/index.mjs":{"imports":[{"path":"@real-router/core","kind":"import-statement","external":true},{"path":"@real-router/core","kind":"import-statement","external":true}],"exports":["hashPluginFactory","isState"],"entryPoint":"src/index.ts","inputs":{"src/factory.ts":{"bytesInOutput":906},"../type-guards/dist/esm/index.mjs":{"bytesInOutput":2337},"../browser-env/dist/esm/index.mjs":{"bytesInOutput":4688},"src/constants.ts":{"bytesInOutput":141},"src/hash-utils.ts":{"bytesInOutput":1091},"src/plugin.ts":{"bytesInOutput":1615},"src/validation.ts":{"bytesInOutput":63},"src/index.ts":{"bytesInOutput":0}},"bytes":11085}}}
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@real-router/hash-plugin",
3
+ "version": "0.0.1",
4
+ "type": "commonjs",
5
+ "description": "Hash-based routing plugin for Real-Router",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.mjs",
8
+ "types": "./dist/esm/index.d.mts",
9
+ "exports": {
10
+ ".": {
11
+ "development": "./src/index.ts",
12
+ "types": {
13
+ "import": "./dist/esm/index.d.mts",
14
+ "require": "./dist/cjs/index.d.ts"
15
+ },
16
+ "import": "./dist/esm/index.mjs",
17
+ "require": "./dist/cjs/index.js"
18
+ }
19
+ },
20
+ "files": [
21
+ "dist",
22
+ "src"
23
+ ],
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/greydragon888/real-router.git"
27
+ },
28
+ "keywords": [
29
+ "real-router",
30
+ "hash",
31
+ "routing",
32
+ "navigation"
33
+ ],
34
+ "author": {
35
+ "name": "Oleg Ivanov",
36
+ "email": "greydragon888@gmail.com",
37
+ "url": "https://github.com/greydragon888"
38
+ },
39
+ "license": "MIT",
40
+ "homepage": "https://github.com/greydragon888/real-router",
41
+ "bugs": {
42
+ "url": "https://github.com/greydragon888/real-router/issues"
43
+ },
44
+ "scripts": {
45
+ "test": "vitest",
46
+ "build": "tsup",
47
+ "type-check": "tsc --noEmit",
48
+ "lint": "eslint --cache --ext .ts src/ tests/ --fix --max-warnings 0",
49
+ "lint:package": "publint",
50
+ "lint:types": "attw --pack ."
51
+ },
52
+ "sideEffects": false,
53
+ "dependencies": {
54
+ "@real-router/core": "workspace:^"
55
+ },
56
+ "devDependencies": {
57
+ "@testing-library/jest-dom": "6.9.1",
58
+ "browser-env": "workspace:^",
59
+ "jsdom": "27.4.0",
60
+ "type-guards": "workspace:^"
61
+ }
62
+ }
@@ -0,0 +1,16 @@
1
+ // packages/hash-plugin/src/constants.ts
2
+
3
+ import type { HashPluginOptions } from "./types";
4
+
5
+ export const defaultOptions: Required<HashPluginOptions> = {
6
+ hashPrefix: "",
7
+ base: "",
8
+ forceDeactivate: true,
9
+ };
10
+
11
+ /**
12
+ * Source identifier for transitions triggered by browser events.
13
+ */
14
+ export const source = "popstate";
15
+
16
+ export const LOGGER_CONTEXT = "hash-plugin";
package/src/factory.ts ADDED
@@ -0,0 +1,63 @@
1
+ import { getPluginApi } from "@real-router/core";
2
+ import {
3
+ createSafeBrowser,
4
+ normalizeBase,
5
+ safelyEncodePath,
6
+ } from "browser-env";
7
+
8
+ import { defaultOptions, source } from "./constants";
9
+ import { createRegExpCache, extractHashPath } from "./hash-utils";
10
+ import { HashPlugin } from "./plugin";
11
+ import { validateOptions } from "./validation";
12
+
13
+ import type { HashPluginOptions } from "./types";
14
+ import type { PluginFactory, Router } from "@real-router/core";
15
+ import type { Browser, SharedFactoryState } from "browser-env";
16
+
17
+ export function hashPluginFactory(
18
+ opts?: Partial<HashPluginOptions>,
19
+ browser?: Browser,
20
+ ): PluginFactory {
21
+ validateOptions(opts);
22
+
23
+ const options: Required<HashPluginOptions> = { ...defaultOptions, ...opts };
24
+
25
+ options.base = normalizeBase(options.base);
26
+
27
+ const regExpCache = createRegExpCache();
28
+ const resolvedBrowser =
29
+ browser ??
30
+ createSafeBrowser(
31
+ () =>
32
+ safelyEncodePath(
33
+ extractHashPath(
34
+ globalThis.location.hash,
35
+ options.hashPrefix,
36
+ regExpCache,
37
+ ),
38
+ ) + globalThis.location.search,
39
+ "hash-plugin",
40
+ );
41
+
42
+ const transitionOptions = {
43
+ forceDeactivate: options.forceDeactivate,
44
+ source,
45
+ replace: true as const,
46
+ };
47
+
48
+ const shared: SharedFactoryState = { removePopStateListener: undefined };
49
+
50
+ return function hashPlugin(routerBase) {
51
+ const plugin = new HashPlugin(
52
+ routerBase as Router,
53
+ getPluginApi(routerBase),
54
+ options,
55
+ resolvedBrowser,
56
+ regExpCache,
57
+ transitionOptions,
58
+ shared,
59
+ );
60
+
61
+ return plugin.getPlugin();
62
+ };
63
+ }
@@ -0,0 +1,79 @@
1
+ // packages/hash-plugin/src/hash-utils.ts
2
+
3
+ import { safeParseUrl } from "browser-env";
4
+
5
+ import { LOGGER_CONTEXT } from "./constants";
6
+
7
+ export interface RegExpCache {
8
+ get: (pattern: string) => RegExp;
9
+ }
10
+
11
+ const escapeRegExpCache = new Map<string, string>();
12
+
13
+ export const escapeRegExp = (str: string): string => {
14
+ const cached = escapeRegExpCache.get(str);
15
+
16
+ if (cached !== undefined) {
17
+ return cached;
18
+ }
19
+
20
+ const escaped = str.replaceAll(/[$()*+.?[\\\]^{|}-]/g, String.raw`\$&`);
21
+
22
+ escapeRegExpCache.set(str, escaped);
23
+
24
+ return escaped;
25
+ };
26
+
27
+ export function createRegExpCache(): RegExpCache {
28
+ const cache = new Map<string, RegExp>();
29
+
30
+ return {
31
+ get(pattern: string): RegExp {
32
+ const cached = cache.get(pattern);
33
+
34
+ if (cached !== undefined) {
35
+ return cached;
36
+ }
37
+
38
+ const newRegExp = new RegExp(pattern);
39
+
40
+ cache.set(pattern, newRegExp);
41
+
42
+ return newRegExp;
43
+ },
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Extract path from URL hash, stripping hash prefix.
49
+ *
50
+ * @param hash - URL hash (e.g., "#/path" or "#!/path")
51
+ * @param hashPrefix - Hash prefix to strip (e.g., "!")
52
+ * @param regExpCache - RegExp cache for compiled patterns
53
+ * @returns Extracted path (e.g., "/path")
54
+ */
55
+ export function extractHashPath(
56
+ hash: string,
57
+ hashPrefix: string,
58
+ regExpCache: RegExpCache,
59
+ ): string {
60
+ const escapedHashPrefix = escapeRegExp(hashPrefix);
61
+ const path = escapedHashPrefix
62
+ ? hash.replace(regExpCache.get(`^#${escapedHashPrefix}`), "")
63
+ : hash.slice(1);
64
+
65
+ return path || "/";
66
+ }
67
+
68
+ export function hashUrlToPath(
69
+ url: string,
70
+ hashPrefix: string,
71
+ regExpCache: RegExpCache,
72
+ ): string | null {
73
+ const parsedUrl = safeParseUrl(url, LOGGER_CONTEXT);
74
+
75
+ return parsedUrl
76
+ ? extractHashPath(parsedUrl.hash, hashPrefix, regExpCache) +
77
+ parsedUrl.search
78
+ : null;
79
+ }
package/src/index.ts ADDED
@@ -0,0 +1,48 @@
1
+ // packages/hash-plugin/src/index.ts
2
+ /* eslint-disable @typescript-eslint/method-signature-style -- method syntax required for declaration merging overload (property syntax causes TS2717) */
3
+ // Public API exports for hash-plugin
4
+
5
+ import type { Params, State } from "@real-router/core";
6
+
7
+ // Main plugin factory
8
+ export { hashPluginFactory } from "./factory";
9
+
10
+ // Types
11
+ export type { HashPluginOptions } from "./types";
12
+
13
+ export type { Browser } from "browser-env";
14
+
15
+ // Type guards
16
+ export { isStateStrict as isState } from "type-guards";
17
+
18
+ /**
19
+ * Module augmentation for real-router.
20
+ * Extends Router interface with hash plugin methods.
21
+ */
22
+ declare module "@real-router/core" {
23
+ interface Router {
24
+ /**
25
+ * Builds full URL for a route with base path and hash prefix.
26
+ * Added by hash plugin.
27
+ */
28
+ buildUrl: (name: string, params?: Params) => string;
29
+
30
+ /**
31
+ * Matches URL and returns corresponding state.
32
+ * Added by hash plugin.
33
+ */
34
+ matchUrl: (url: string) => State | undefined;
35
+
36
+ /**
37
+ * Replaces current history state without triggering navigation.
38
+ * Added by hash plugin.
39
+ */
40
+ replaceHistoryState: (
41
+ name: string,
42
+ params?: Params,
43
+ title?: string,
44
+ ) => void;
45
+
46
+ start(path?: string): Promise<State>;
47
+ }
48
+ }
package/src/plugin.ts ADDED
@@ -0,0 +1,113 @@
1
+ import {
2
+ createPopstateHandler,
3
+ createPopstateLifecycle,
4
+ createStartInterceptor,
5
+ createReplaceHistoryState,
6
+ shouldReplaceHistory,
7
+ updateBrowserState,
8
+ } from "browser-env";
9
+
10
+ import { hashUrlToPath } from "./hash-utils";
11
+
12
+ import type { RegExpCache } from "./hash-utils";
13
+ import type { HashPluginOptions } from "./types";
14
+ import type {
15
+ NavigationOptions,
16
+ Params,
17
+ PluginApi,
18
+ Router,
19
+ State,
20
+ Plugin,
21
+ } from "@real-router/core";
22
+ import type { Browser, SharedFactoryState } from "browser-env";
23
+
24
+ export class HashPlugin {
25
+ readonly #router: Router;
26
+ readonly #browser: Browser;
27
+ readonly #removeStartInterceptor: () => void;
28
+ readonly #removeExtensions: () => void;
29
+ readonly #lifecycle: Pick<Plugin, "onStart" | "onStop" | "teardown">;
30
+
31
+ constructor(
32
+ router: Router,
33
+ api: PluginApi,
34
+ options: Required<HashPluginOptions>,
35
+ browser: Browser,
36
+ regExpCache: RegExpCache,
37
+ transitionOptions: {
38
+ source: string;
39
+ replace: true;
40
+ forceDeactivate?: boolean;
41
+ },
42
+ shared: SharedFactoryState,
43
+ ) {
44
+ this.#router = router;
45
+ this.#browser = browser;
46
+
47
+ this.#removeStartInterceptor = createStartInterceptor(api, browser);
48
+
49
+ const pluginBuildUrl = (route: string, params?: Params) => {
50
+ const path = router.buildPath(route, params);
51
+
52
+ return `${options.base}#${options.hashPrefix}${path}`;
53
+ };
54
+
55
+ this.#removeExtensions = api.extendRouter({
56
+ buildUrl: pluginBuildUrl,
57
+ matchUrl: (url: string) => {
58
+ const path = hashUrlToPath(url, options.hashPrefix, regExpCache);
59
+
60
+ return path ? api.matchPath(path) : undefined;
61
+ },
62
+ replaceHistoryState: createReplaceHistoryState(
63
+ api,
64
+ router,
65
+ browser,
66
+ pluginBuildUrl,
67
+ ),
68
+ });
69
+
70
+ const handler = createPopstateHandler({
71
+ router,
72
+ api,
73
+ browser,
74
+ transitionOptions,
75
+ loggerContext: "hash-plugin",
76
+ buildUrl: (name: string, params?: Params) =>
77
+ router.buildUrl(name, params),
78
+ });
79
+
80
+ this.#lifecycle = createPopstateLifecycle({
81
+ browser,
82
+ shared,
83
+ handler,
84
+ cleanup: () => {
85
+ this.#removeStartInterceptor();
86
+ this.#removeExtensions();
87
+ },
88
+ });
89
+ }
90
+
91
+ getPlugin(): Plugin {
92
+ return {
93
+ ...this.#lifecycle,
94
+
95
+ onTransitionSuccess: (
96
+ toState: State,
97
+ fromState: State | undefined,
98
+ navOptions: NavigationOptions,
99
+ ) => {
100
+ const replaceHistory = shouldReplaceHistory(
101
+ navOptions,
102
+ toState,
103
+ fromState,
104
+ this.#router,
105
+ );
106
+
107
+ const url = this.#router.buildUrl(toState.name, toState.params);
108
+
109
+ updateBrowserState(toState, url, replaceHistory, this.#browser);
110
+ },
111
+ };
112
+ }
113
+ }
package/src/types.ts ADDED
@@ -0,0 +1,28 @@
1
+ // packages/hash-plugin/src/types.ts
2
+
3
+ /**
4
+ * Hash-based routing plugin configuration.
5
+ * Uses URL hash fragment for navigation (e.g., example.com/#/path).
6
+ */
7
+ export interface HashPluginOptions {
8
+ /**
9
+ * Prefix for hash (e.g., "!" for "#!/path").
10
+ *
11
+ * @default ""
12
+ */
13
+ hashPrefix?: string;
14
+
15
+ /**
16
+ * Base path prepended before hash (e.g., "/app" → "/app#/path").
17
+ *
18
+ * @default ""
19
+ */
20
+ base?: string;
21
+
22
+ /**
23
+ * Force deactivation of current route even if canDeactivate returns false.
24
+ *
25
+ * @default true
26
+ */
27
+ forceDeactivate?: boolean;
28
+ }
@@ -0,0 +1,10 @@
1
+ import { createOptionsValidator } from "browser-env";
2
+
3
+ import { LOGGER_CONTEXT, defaultOptions } from "./constants";
4
+
5
+ import type { HashPluginOptions } from "./types";
6
+
7
+ export const validateOptions = createOptionsValidator<HashPluginOptions>(
8
+ defaultOptions,
9
+ LOGGER_CONTEXT,
10
+ );