@preact-mvc/core 0.1.0 → 0.1.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/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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }
@@ -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
- }