@preact-mvc/core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +96 -0
- package/dist/context.d.ts +10 -0
- package/dist/context.js +30 -0
- package/dist/controller.d.ts +8 -0
- package/dist/controller.js +21 -0
- package/dist/decorators.d.ts +22 -0
- package/dist/decorators.js +38 -0
- package/{src/index.ts → dist/index.d.ts} +1 -21
- package/dist/index.js +15 -0
- package/dist/injector.d.ts +47 -0
- package/dist/injector.js +202 -0
- package/dist/module.d.ts +7 -0
- package/dist/module.js +69 -0
- package/dist/router/index.js +4 -0
- package/dist/router/link.d.ts +7 -0
- package/dist/router/link.js +16 -0
- package/dist/router/module.d.ts +35 -0
- package/dist/router/module.js +32 -0
- package/dist/router/outlet.d.ts +1 -0
- package/dist/router/outlet.js +44 -0
- package/dist/router/router.d.ts +52 -0
- package/dist/router/router.js +151 -0
- package/dist/signals.d.ts +4 -0
- package/{src/signals.ts → dist/signals.js} +4 -7
- package/dist/view.d.ts +8 -0
- package/dist/view.js +133 -0
- package/package.json +12 -8
- package/src/context.tsx +0 -39
- package/src/controller.ts +0 -30
- package/src/decorators.ts +0 -77
- package/src/injector.ts +0 -228
- package/src/module.ts +0 -76
- package/src/router/link.tsx +0 -25
- package/src/router/module.ts +0 -42
- package/src/router/outlet.tsx +0 -65
- package/src/router/router.ts +0 -165
- package/src/view.ts +0 -153
- /package/{src/router/index.ts → dist/router/index.d.ts} +0 -0
package/src/injector.ts
DELETED
|
@@ -1,228 +0,0 @@
|
|
|
1
|
-
export type Type<T> = new (...args: any[]) => T;
|
|
2
|
-
|
|
3
|
-
export class InjectionToken<T> {
|
|
4
|
-
constructor(protected readonly _desc: string) {}
|
|
5
|
-
toString() {
|
|
6
|
-
return `InjectionToken ${this._desc}`;
|
|
7
|
-
}
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export type Token<T> = Type<T> | InjectionToken<T> | (abstract new (...args: any[]) => T);
|
|
11
|
-
|
|
12
|
-
export type Provider =
|
|
13
|
-
| Type<any>
|
|
14
|
-
| { provide: Token<any>; useClass: Type<any> }
|
|
15
|
-
| { provide: Token<any>; useValue: any }
|
|
16
|
-
| { provide: Token<any>; useFactory: (...deps: any[]) => any; deps?: Token<any>[] };
|
|
17
|
-
|
|
18
|
-
interface Record {
|
|
19
|
-
factory: () => any;
|
|
20
|
-
resolving: boolean;
|
|
21
|
-
resolved: boolean;
|
|
22
|
-
value: any;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// --- Injection context -----------------------------------------------------
|
|
26
|
-
// While an Injector constructs a class (service, controller, factory), it
|
|
27
|
-
// becomes the "current" injector so `inject()` calls inside the constructor
|
|
28
|
-
// or field initializers resolve against it — the same model as Angular's
|
|
29
|
-
// injection context.
|
|
30
|
-
|
|
31
|
-
let currentInjector: Injector | null = null;
|
|
32
|
-
|
|
33
|
-
export function runInInjectionContext<T>(injector: Injector, fn: () => T): T {
|
|
34
|
-
const prev = currentInjector;
|
|
35
|
-
currentInjector = injector;
|
|
36
|
-
try {
|
|
37
|
-
return fn();
|
|
38
|
-
} finally {
|
|
39
|
-
currentInjector = prev;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
export function inject<T>(token: Token<T>): T {
|
|
44
|
-
if (!currentInjector) {
|
|
45
|
-
throw new Error(
|
|
46
|
-
'inject() called outside an injection context. It can only be used in the ' +
|
|
47
|
-
'constructor/field initializers of a class instantiated by the framework.'
|
|
48
|
-
);
|
|
49
|
-
}
|
|
50
|
-
return currentInjector.get(token);
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function getCurrentInjector(): Injector | null {
|
|
54
|
-
return currentInjector;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// --- Injector ---------------------------------------------------------------
|
|
58
|
-
|
|
59
|
-
function tokenName(token: Token<any>): string {
|
|
60
|
-
return typeof token === 'function' ? token.name : token.toString();
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// Injectable handle to the enclosing scope's destruction — inject(DestroyRef)
|
|
64
|
-
// anywhere to register cleanup that runs when the scope is deactivated.
|
|
65
|
-
export class DestroyRef {
|
|
66
|
-
constructor(private readonly injector: Injector) {}
|
|
67
|
-
onDestroy(fn: () => void): void {
|
|
68
|
-
this.injector.registerCleanup(fn);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
export class Injector {
|
|
73
|
-
private records = new Map<Token<any>, Record>();
|
|
74
|
-
|
|
75
|
-
// Every module whose providers AND declared components are visible in this
|
|
76
|
-
// scope: the module this injector was created for plus its transitive
|
|
77
|
-
// imports. Populated by registerModule(); read by view-mount enforcement.
|
|
78
|
-
readonly modules = new Set<any>();
|
|
79
|
-
|
|
80
|
-
constructor(
|
|
81
|
-
readonly parent: Injector | null = null,
|
|
82
|
-
readonly name = 'injector'
|
|
83
|
-
) {
|
|
84
|
-
this.records.set(DestroyRef, {
|
|
85
|
-
factory: () => null,
|
|
86
|
-
resolving: false,
|
|
87
|
-
resolved: true,
|
|
88
|
-
value: new DestroyRef(this),
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
get root(): Injector {
|
|
93
|
-
let node: Injector = this;
|
|
94
|
-
while (node.parent) node = node.parent;
|
|
95
|
-
return node;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
provide(provider: Provider): void {
|
|
99
|
-
if (typeof provider === 'function') {
|
|
100
|
-
const cls = provider;
|
|
101
|
-
this.records.set(cls, {
|
|
102
|
-
factory: () => this.instantiate(cls),
|
|
103
|
-
resolving: false,
|
|
104
|
-
resolved: false,
|
|
105
|
-
value: undefined,
|
|
106
|
-
});
|
|
107
|
-
} else if ('useValue' in provider) {
|
|
108
|
-
this.records.set(provider.provide, {
|
|
109
|
-
factory: () => provider.useValue,
|
|
110
|
-
resolving: false,
|
|
111
|
-
resolved: false,
|
|
112
|
-
value: undefined,
|
|
113
|
-
});
|
|
114
|
-
} else if ('useClass' in provider) {
|
|
115
|
-
const cls = provider.useClass;
|
|
116
|
-
this.records.set(provider.provide, {
|
|
117
|
-
factory: () => this.instantiate(cls),
|
|
118
|
-
resolving: false,
|
|
119
|
-
resolved: false,
|
|
120
|
-
value: undefined,
|
|
121
|
-
});
|
|
122
|
-
} else {
|
|
123
|
-
const { useFactory, deps = [] } = provider;
|
|
124
|
-
this.records.set(provider.provide, {
|
|
125
|
-
factory: () => useFactory(...deps.map((d) => this.get(d))),
|
|
126
|
-
resolving: false,
|
|
127
|
-
resolved: false,
|
|
128
|
-
value: undefined,
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
has(token: Token<any>): boolean {
|
|
134
|
-
if (this.records.has(token)) return true;
|
|
135
|
-
if ((token as any).__controller) return true;
|
|
136
|
-
if (this.parent) return this.parent.has(token);
|
|
137
|
-
return this.isRootProvided(token);
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
get<T>(token: Token<T>): T {
|
|
141
|
-
// Nearest explicit registration wins, walking up the scope chain. This is
|
|
142
|
-
// what makes declaring a controller/provider in a scope meaningful: every
|
|
143
|
-
// descendant resolves that shared instance.
|
|
144
|
-
let node: Injector | null = this;
|
|
145
|
-
while (node) {
|
|
146
|
-
const rec = node.records.get(token);
|
|
147
|
-
if (rec) return node.resolveRecord(token, rec);
|
|
148
|
-
node = node.parent;
|
|
149
|
-
}
|
|
150
|
-
// Controllers are transient unless explicitly provided: one instance per
|
|
151
|
-
// requesting scope, so a view always gets its own controller.
|
|
152
|
-
if ((token as any).__controller) {
|
|
153
|
-
this.provide(token as Type<any>);
|
|
154
|
-
return this.resolveRecord(token, this.records.get(token)!);
|
|
155
|
-
}
|
|
156
|
-
// providedIn: 'root' lazily registers at the top — local providers still
|
|
157
|
-
// shadow it because the chain walk runs first.
|
|
158
|
-
const root = this.root;
|
|
159
|
-
if (root.isRootProvided(token)) {
|
|
160
|
-
root.provide({ provide: token, useClass: token as Type<T> });
|
|
161
|
-
return root.get(token);
|
|
162
|
-
}
|
|
163
|
-
throw new Error(`No provider for ${tokenName(token)} (looked up from "${this.name}")`);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
private resolveRecord<T>(token: Token<T>, rec: Record): T {
|
|
167
|
-
if (rec.resolving) {
|
|
168
|
-
throw new Error(`Circular dependency detected while resolving ${tokenName(token)}`);
|
|
169
|
-
}
|
|
170
|
-
if (!rec.resolved) {
|
|
171
|
-
rec.resolving = true;
|
|
172
|
-
try {
|
|
173
|
-
rec.value = rec.factory();
|
|
174
|
-
rec.resolved = true;
|
|
175
|
-
} finally {
|
|
176
|
-
rec.resolving = false;
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return rec.value;
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
private isRootProvided(token: Token<any>): boolean {
|
|
183
|
-
const meta = (token as any).__injectable as { providedIn?: 'root' } | undefined;
|
|
184
|
-
return meta?.providedIn === 'root';
|
|
185
|
-
}
|
|
186
|
-
|
|
187
|
-
instantiate<T>(cls: Type<T>, ...args: any[]): T {
|
|
188
|
-
const inst = runInInjectionContext(this, () => new cls(...args));
|
|
189
|
-
this.trackLifecycle(inst);
|
|
190
|
-
return inst;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
// --- Scope lifecycle -----------------------------------------------------
|
|
194
|
-
// Every class this injector constructs (view, injected controllers,
|
|
195
|
-
// view-scoped providers) is managed together: activate() fires onInit on
|
|
196
|
-
// all of them at mount, deactivate() fires onDestroy at unmount — the same
|
|
197
|
-
// way Angular ties provider lifecycles to their injector's scope.
|
|
198
|
-
|
|
199
|
-
private managed: any[] = [];
|
|
200
|
-
private active = false;
|
|
201
|
-
|
|
202
|
-
private trackLifecycle(inst: any): void {
|
|
203
|
-
if (typeof inst?.onInit !== 'function' && typeof inst?.onDestroy !== 'function') return;
|
|
204
|
-
this.managed.push(inst);
|
|
205
|
-
if (this.active) inst.onInit?.();
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
activate(): void {
|
|
209
|
-
this.active = true;
|
|
210
|
-
for (const inst of this.managed) inst.onInit?.();
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
deactivate(): void {
|
|
214
|
-
for (const inst of this.managed) inst.onDestroy?.();
|
|
215
|
-
for (const cleanup of this.cleanups) cleanup();
|
|
216
|
-
this.managed.length = 0;
|
|
217
|
-
this.cleanups.length = 0;
|
|
218
|
-
this.active = false;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
private cleanups: Array<() => void> = [];
|
|
222
|
-
|
|
223
|
-
// Register teardown (effect disposers, subscriptions, timers) that runs
|
|
224
|
-
// when this scope is deactivated. Exposed to classes via DestroyRef.
|
|
225
|
-
registerCleanup(fn: () => void): void {
|
|
226
|
-
this.cleanups.push(fn);
|
|
227
|
-
}
|
|
228
|
-
}
|
package/src/module.ts
DELETED
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
import { h, render } from 'preact';
|
|
2
|
-
import { Injector } from './injector';
|
|
3
|
-
import { InjectorContext } from './context';
|
|
4
|
-
import { installViewInterop } from './view';
|
|
5
|
-
import type { ModuleMeta } from './decorators';
|
|
6
|
-
|
|
7
|
-
// Modules are the unit of provider scoping. A module's providers (plus those
|
|
8
|
-
// of its imports, transitively) register into a single child Injector. The
|
|
9
|
-
// injector tree mirrors the view tree: root injector -> AppModule injector ->
|
|
10
|
-
// feature/lazy module injectors -> per-view injectors.
|
|
11
|
-
|
|
12
|
-
export function getModuleMeta(mod: any): ModuleMeta | undefined {
|
|
13
|
-
return mod?.__module;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
// Applies a module to an injector: registers all transitively imported
|
|
17
|
-
// modules (and their providers/controllers) into it and marks them visible
|
|
18
|
-
// on injector.modules. Used for module injectors and standalone-component
|
|
19
|
-
// `imports` alike.
|
|
20
|
-
export function registerModule(mod: any, injector: Injector, seen: Set<any>): void {
|
|
21
|
-
if (seen.has(mod)) return;
|
|
22
|
-
seen.add(mod);
|
|
23
|
-
const meta = getModuleMeta(mod);
|
|
24
|
-
if (!meta) {
|
|
25
|
-
throw new Error(`${mod?.name ?? mod} is not a module — missing @Module() metadata`);
|
|
26
|
-
}
|
|
27
|
-
for (const imp of meta.imports ?? []) registerModule(imp, injector, seen);
|
|
28
|
-
injector.modules.add(mod);
|
|
29
|
-
for (const provider of meta.providers ?? []) injector.provide(provider);
|
|
30
|
-
// Module-declared controllers register at module scope: one shared
|
|
31
|
-
// instance for everything injected under that module.
|
|
32
|
-
for (const c of meta.controllers ?? []) injector.provide(c);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createModuleInjector(mod: any, parent: Injector): Injector {
|
|
36
|
-
const meta = getModuleMeta(mod);
|
|
37
|
-
if (!meta) {
|
|
38
|
-
throw new Error(`${mod?.name ?? mod} is not a module — missing @Module() metadata`);
|
|
39
|
-
}
|
|
40
|
-
const injector = new Injector(parent, `module ${mod.name ?? '(anonymous)'}`);
|
|
41
|
-
registerModule(mod, injector, new Set());
|
|
42
|
-
return injector;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// Lazy/feature modules get one injector per parent injector, cached so a
|
|
46
|
-
// route-mounted module's providers stay alive across navigations.
|
|
47
|
-
const moduleInjectorCache = new WeakMap<Injector, Map<any, Injector>>();
|
|
48
|
-
|
|
49
|
-
export function getModuleInjector(mod: any, parent: Injector): Injector {
|
|
50
|
-
let byModule = moduleInjectorCache.get(parent);
|
|
51
|
-
if (!byModule) {
|
|
52
|
-
byModule = new Map();
|
|
53
|
-
moduleInjectorCache.set(parent, byModule);
|
|
54
|
-
}
|
|
55
|
-
let injector = byModule.get(mod);
|
|
56
|
-
if (!injector) {
|
|
57
|
-
injector = createModuleInjector(mod, parent);
|
|
58
|
-
byModule.set(mod, injector);
|
|
59
|
-
}
|
|
60
|
-
return injector;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function bootstrap(RootModule: any, el: HTMLElement): void {
|
|
64
|
-
installViewInterop();
|
|
65
|
-
const root = new Injector(null, 'root');
|
|
66
|
-
const appInjector = createModuleInjector(RootModule, root);
|
|
67
|
-
const meta = getModuleMeta(RootModule)!;
|
|
68
|
-
const App = meta.bootstrap?.[0];
|
|
69
|
-
if (!App) {
|
|
70
|
-
throw new Error('Root module must declare a bootstrap component: @Module({ bootstrap: [AppView] })');
|
|
71
|
-
}
|
|
72
|
-
render(
|
|
73
|
-
h(InjectorContext.Provider, { value: appInjector }, h(App, {})),
|
|
74
|
-
el
|
|
75
|
-
);
|
|
76
|
-
}
|
package/src/router/link.tsx
DELETED
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
import type { ComponentChildren } from 'preact';
|
|
2
|
-
import type { JSX } from 'preact';
|
|
3
|
-
import { useService } from '../context';
|
|
4
|
-
import { Router } from './router';
|
|
5
|
-
|
|
6
|
-
export function Link(props: {
|
|
7
|
-
to: string;
|
|
8
|
-
class?: string;
|
|
9
|
-
children?: ComponentChildren;
|
|
10
|
-
}) {
|
|
11
|
-
const router = useService(Router);
|
|
12
|
-
const onClick = (e: JSX.TargetedMouseEvent<HTMLAnchorElement>) => {
|
|
13
|
-
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0) return;
|
|
14
|
-
e.preventDefault();
|
|
15
|
-
// preact-router's document-level <a> interceptor routes any plain
|
|
16
|
-
// anchor click it sees — stop propagation so ours doesn't double-nav.
|
|
17
|
-
e.stopPropagation();
|
|
18
|
-
router.navigate(props.to);
|
|
19
|
-
};
|
|
20
|
-
return (
|
|
21
|
-
<a href={router.href(props.to)} class={props.class} onClick={onClick}>
|
|
22
|
-
{props.children}
|
|
23
|
-
</a>
|
|
24
|
-
);
|
|
25
|
-
}
|
package/src/router/module.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
import { getModuleMeta } from '../module';
|
|
2
|
-
import { ROUTES, ROUTER_BASE, Router, defaultBasePath } from './router';
|
|
3
|
-
import type { Route } from './router';
|
|
4
|
-
|
|
5
|
-
export interface RouterOptions {
|
|
6
|
-
/**
|
|
7
|
-
* Mount prefix the app is served under ('/demo/'), Angular's APP_BASE_HREF.
|
|
8
|
-
* Defaults to the document's own directory (document.baseURI), so apps
|
|
9
|
-
* hosted under a subpath need no config; '/' disables prefixing.
|
|
10
|
-
*/
|
|
11
|
-
basePath?: string;
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
// RouterModule.forRoot(routes) returns a module-shaped object — anything with
|
|
15
|
-
// __module metadata can sit in `imports`, so static config works the same as
|
|
16
|
-
// a decorated module class.
|
|
17
|
-
export class RouterModule {
|
|
18
|
-
static forRoot(routes: Route[], opts: RouterOptions = {}) {
|
|
19
|
-
return {
|
|
20
|
-
__module: {
|
|
21
|
-
providers: [
|
|
22
|
-
{ provide: ROUTES, useValue: routes },
|
|
23
|
-
{ provide: ROUTER_BASE, useValue: opts.basePath ?? defaultBasePath() },
|
|
24
|
-
{
|
|
25
|
-
provide: Router,
|
|
26
|
-
useFactory: (r: Route[], base: string) => new Router(r, base),
|
|
27
|
-
deps: [ROUTES, ROUTER_BASE],
|
|
28
|
-
},
|
|
29
|
-
],
|
|
30
|
-
},
|
|
31
|
-
};
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
// Resolve which view a matched route should render: an explicit component, or
|
|
36
|
-
// the module's declared bootstrap component.
|
|
37
|
-
export function routeComponent(match: { route: Route }): any {
|
|
38
|
-
const { route } = match;
|
|
39
|
-
if (route.component) return route.component;
|
|
40
|
-
if (route.module) return getModuleMeta(route.module)?.bootstrap?.[0] ?? null;
|
|
41
|
-
return null;
|
|
42
|
-
}
|
package/src/router/outlet.tsx
DELETED
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
import { createContext } from 'preact';
|
|
2
|
-
import type { FunctionComponent } from 'preact';
|
|
3
|
-
import { useContext } from 'preact/hooks';
|
|
4
|
-
import PRRouter from 'preact-router';
|
|
5
|
-
import type { RoutableProps } from 'preact-router';
|
|
6
|
-
import { useInjector, useService, InjectorContext } from '../context';
|
|
7
|
-
import { getModuleInjector } from '../module';
|
|
8
|
-
import { routeComponent } from './module';
|
|
9
|
-
import { Router } from './router';
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Which depth of the matched chain this outlet renders — the root outlet
|
|
13
|
-
* is depth 0, an outlet inside a routed view is depth 1, and so on.
|
|
14
|
-
*/
|
|
15
|
-
const OutletDepth = createContext(0);
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* The preact-router bridge: a mounted PR <Router> claiming every URL via a
|
|
19
|
-
* `default` sink child. Its existence makes preact-router's machinery live
|
|
20
|
-
* in this app — foreign `route()` calls and the global `<a href>` click
|
|
21
|
-
* interceptor resolve through it — and its onChange feeds our `url`
|
|
22
|
-
* signal, so navigations from ANY preact-routing code drive our router.
|
|
23
|
-
* The sink renders nothing; url/matches props die on the component.
|
|
24
|
-
*/
|
|
25
|
-
const BridgeSink: FunctionComponent<RoutableProps> = () => null;
|
|
26
|
-
|
|
27
|
-
export function RouterOutlet() {
|
|
28
|
-
const router = useService(Router);
|
|
29
|
-
const parent = useInjector();
|
|
30
|
-
const depth = useContext(OutletDepth);
|
|
31
|
-
const matches = router.matches.value;
|
|
32
|
-
const match = matches?.[depth];
|
|
33
|
-
if (!match) {
|
|
34
|
-
// The outermost outlet keeps the bridge mounted even with no match, so
|
|
35
|
-
// route() interop survives 404s.
|
|
36
|
-
return depth === 0 ? (
|
|
37
|
-
<PRRouter onChange={(e) => router.syncUrl(e.url)}>
|
|
38
|
-
<BridgeSink default />
|
|
39
|
-
</PRRouter>
|
|
40
|
-
) : null;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
const Component = routeComponent(match);
|
|
44
|
-
if (!Component) return null;
|
|
45
|
-
|
|
46
|
-
// A route with `module` gets the module's injector — its providers are
|
|
47
|
-
// scoped to that feature. Nesting is automatic: a deeper outlet resolves
|
|
48
|
-
// its module under this level's injector.
|
|
49
|
-
const injector = match.route.module
|
|
50
|
-
? getModuleInjector(match.route.module, parent)
|
|
51
|
-
: parent;
|
|
52
|
-
|
|
53
|
-
return (
|
|
54
|
-
<OutletDepth.Provider value={depth + 1}>
|
|
55
|
-
{depth === 0 && (
|
|
56
|
-
<PRRouter onChange={(e) => router.syncUrl(e.url)}>
|
|
57
|
-
<BridgeSink default />
|
|
58
|
-
</PRRouter>
|
|
59
|
-
)}
|
|
60
|
-
<InjectorContext.Provider value={injector}>
|
|
61
|
-
<Component />
|
|
62
|
-
</InjectorContext.Provider>
|
|
63
|
-
</OutletDepth.Provider>
|
|
64
|
-
);
|
|
65
|
-
}
|
package/src/router/router.ts
DELETED
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
import { computed, signal } from '@preact/signals';
|
|
2
|
-
import { route as prRoute } from 'preact-router';
|
|
3
|
-
import { Injectable } from '../decorators';
|
|
4
|
-
import { InjectionToken } from '../injector';
|
|
5
|
-
|
|
6
|
-
export interface Route {
|
|
7
|
-
path: string;
|
|
8
|
-
component?: any; // view function
|
|
9
|
-
module?: any; // module class — its bootstrap component renders inside its injector
|
|
10
|
-
children?: Route[];
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface RouteMatch {
|
|
14
|
-
route: Route;
|
|
15
|
-
params: Record<string, string>;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export const ROUTES = new InjectionToken<Route[]>('ROUTES');
|
|
19
|
-
export const ROUTER_BASE = new InjectionToken<string>('ROUTER_BASE');
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* The directory the app's document lives at, e.g. '/demo/' when the page is
|
|
23
|
-
* served from `host/demo/index.html`. Deriving the base from baseURI (rather
|
|
24
|
-
* than baking it at build time) means the same bundle works under any mount
|
|
25
|
-
* path — GitHub Pages project dirs, reverse-proxy prefixes, iframed demos.
|
|
26
|
-
*/
|
|
27
|
-
export function defaultBasePath(): string {
|
|
28
|
-
if (typeof document === 'undefined') return '/';
|
|
29
|
-
const p = new URL(document.baseURI).pathname;
|
|
30
|
-
return p.endsWith('/') ? p : p.slice(0, p.lastIndexOf('/') + 1);
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/**
|
|
34
|
-
* Prefix match: consume the pattern's leading segments off `segs`, returning
|
|
35
|
-
* the captured params and the unconsumed tail. `'*'` consumes everything;
|
|
36
|
-
* `''` consumes nothing (an index route — leaf check decides validity).
|
|
37
|
-
*/
|
|
38
|
-
function matchPrefix(
|
|
39
|
-
pattern: string,
|
|
40
|
-
segs: string[],
|
|
41
|
-
): { params: Record<string, string>; rest: string[] } | null {
|
|
42
|
-
if (pattern === '*') return { params: {}, rest: [] };
|
|
43
|
-
const pSegs = pattern.split('/').filter(Boolean);
|
|
44
|
-
if (pSegs.length > segs.length) return null;
|
|
45
|
-
const params: Record<string, string> = {};
|
|
46
|
-
for (let i = 0; i < pSegs.length; i++) {
|
|
47
|
-
const p = pSegs[i];
|
|
48
|
-
const u = decodeURIComponent(segs[i]);
|
|
49
|
-
if (p.startsWith(':')) params[p.slice(1)] = u;
|
|
50
|
-
else if (p !== u) return null;
|
|
51
|
-
}
|
|
52
|
-
return { params, rest: segs.slice(pSegs.length) };
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Match `segs` against a route list into a chain outermost → innermost.
|
|
57
|
-
* A route with `children` matches by prefix and the tail recurses; a leaf
|
|
58
|
-
* route must consume the whole tail. Parents may match alone when the tail
|
|
59
|
-
* is exhausted (Angular semantics: `/users` renders the parent shell even
|
|
60
|
-
* if it has children).
|
|
61
|
-
*/
|
|
62
|
-
function matchSegments(routes: Route[], segs: string[]): RouteMatch[] | null {
|
|
63
|
-
for (const route of routes) {
|
|
64
|
-
const m = matchPrefix(route.path, segs);
|
|
65
|
-
if (m === null) continue;
|
|
66
|
-
if (route.children?.length) {
|
|
67
|
-
const child = matchSegments(route.children, m.rest);
|
|
68
|
-
if (child !== null) return [{ route, params: m.params }, ...child];
|
|
69
|
-
if (m.rest.length === 0) return [{ route, params: m.params }];
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
if (m.rest.length === 0) return [{ route, params: m.params }];
|
|
73
|
-
}
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function matchRoutes(routes: Route[], url: string): RouteMatch[] | null {
|
|
78
|
-
const path = url.split('?')[0].split('#')[0];
|
|
79
|
-
return matchSegments(routes, path.split('/').filter(Boolean));
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
@Injectable()
|
|
83
|
-
export class Router {
|
|
84
|
-
/** Mount prefix, normalized to '' or '/demo' (no trailing slash). */
|
|
85
|
-
readonly base: string;
|
|
86
|
-
url = signal(typeof location === 'undefined' ? '/' : '');
|
|
87
|
-
/**
|
|
88
|
-
* The matched chain outermost → innermost — depth d is what the d-th
|
|
89
|
-
* nested <RouterOutlet/> renders.
|
|
90
|
-
*/
|
|
91
|
-
matches = computed(() => matchRoutes(this.routes, this.url.value));
|
|
92
|
-
/** Back-compat: the chain's outermost match. */
|
|
93
|
-
match = computed(() => this.matches.value?.[0] ?? null);
|
|
94
|
-
/** Params merged across the whole chain (inner levels win on collision). */
|
|
95
|
-
params = computed(() =>
|
|
96
|
-
Object.assign({}, ...(this.matches.value ?? []).map((m) => m.params)),
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
constructor(
|
|
100
|
-
private routes: Route[],
|
|
101
|
-
base = defaultBasePath(),
|
|
102
|
-
) {
|
|
103
|
-
this.base = base === '/' ? '' : base.replace(/\/+$/, '');
|
|
104
|
-
this.consumeShimParam();
|
|
105
|
-
if (typeof location !== 'undefined') {
|
|
106
|
-
this.url.value = this.strip(location.pathname) + location.search;
|
|
107
|
-
}
|
|
108
|
-
if (typeof window !== 'undefined') {
|
|
109
|
-
window.addEventListener('popstate', this.onPopState);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Static-host deep links: the site's 404 shim redirects
|
|
115
|
-
* `/docs/<app>/<rest>` to `<app>/?__pmvc_path=<rest>` so the app loads;
|
|
116
|
-
* restore the real URL via replaceState before seeding `url` — the
|
|
117
|
-
* browser history never sees the hop. No-op everywhere else.
|
|
118
|
-
*/
|
|
119
|
-
private consumeShimParam(): void {
|
|
120
|
-
if (typeof location === 'undefined' || typeof history === 'undefined') return;
|
|
121
|
-
const rest = new URL(location.href).searchParams.get('__pmvc_path');
|
|
122
|
-
if (rest === null) return;
|
|
123
|
-
history.replaceState(null, '', this.base + '/' + rest);
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
private strip(path: string): string {
|
|
127
|
-
if (this.base && path.startsWith(this.base)) {
|
|
128
|
-
return path.slice(this.base.length) || '/';
|
|
129
|
-
}
|
|
130
|
-
return path;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Absolute href for an in-app path — what <a href> and pushState see. */
|
|
134
|
-
href(to: string): string {
|
|
135
|
-
return this.base + to;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/** Called by the preact-router bridge on any URL change it sees —
|
|
139
|
-
* foreign route() calls, its global <a> interceptor, popstate. */
|
|
140
|
-
syncUrl(fullUrl: string): void {
|
|
141
|
-
const [path, search = ''] = fullUrl.split('?');
|
|
142
|
-
const stripped = this.strip(path);
|
|
143
|
-
const next = stripped + (search ? `?${search}` : '');
|
|
144
|
-
if (next !== this.url.value) this.url.value = next;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
private onPopState = () => {
|
|
148
|
-
this.syncUrl(location.pathname + location.search);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
navigate(to: string): void {
|
|
152
|
-
// Through preact-router: updates every mounted <Router> in the tree —
|
|
153
|
-
// ours and foreign ones — not just our own subscriber. route() is a
|
|
154
|
-
// no-op when no mounted PR Router claims the url, so fall back to a
|
|
155
|
-
// manual push when the bridge isn't up yet.
|
|
156
|
-
if (!prRoute(this.base + to)) {
|
|
157
|
-
history.pushState(null, '', this.base + to);
|
|
158
|
-
this.url.value = to;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
isActive(path: string): boolean {
|
|
163
|
-
return this.url.value === path;
|
|
164
|
-
}
|
|
165
|
-
}
|