@loozo/angular-router 0.0.7 → 0.0.9
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 +178 -39
- package/fesm2022/loozo-angular-router-_core.mjs +33 -2
- package/fesm2022/loozo-angular-router-_core.mjs.map +1 -1
- package/fesm2022/loozo-angular-router-_generated.mjs +196 -43
- package/fesm2022/loozo-angular-router-_generated.mjs.map +1 -1
- package/fesm2022/loozo-angular-router.mjs +2 -2
- package/node/src/builders/serve.js +82 -87
- package/node/src/generation/emit.js +29 -3
- package/node/src/generation/files.d.ts +1 -0
- package/node/src/generation/files.js +13 -2
- package/node/src/generation/overlay.js +9 -2
- package/node/src/generation/sources.js +12 -5
- package/package.json +1 -1
- package/snippets/generated/destinations.generated.ts +25 -1
- package/snippets/src/routes/products/$productId/route.generated.ts +4 -2
- package/snippets/src/routes/reports/$reportId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/items/$id/inputs.ts +2 -1
- package/snippets/src/routes/teams/$teamId/items/$id/links.ts +6 -4
- package/snippets/src/routes/teams/$teamId/items/$id/page.spec.ts +29 -0
- package/snippets/src/routes/teams/$teamId/items/$id/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/members/$memberId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/route.generated.ts +4 -2
- package/snippets/src/routes/teams/$teamId/route.ts +3 -0
- package/snippets/src/shell.ts +39 -3
- package/types/loozo-angular-router-_core.d.ts +135 -53
- package/types/loozo-angular-router-_generated.d.ts +111 -61
- package/types/loozo-angular-router.d.ts +2 -2
|
@@ -10,8 +10,15 @@ export function overlayHost(options, authored) {
|
|
|
10
10
|
const read = host.readFile.bind(host);
|
|
11
11
|
const exists = host.fileExists.bind(host);
|
|
12
12
|
const directoryExists = host.directoryExists.bind(host);
|
|
13
|
+
// On a case-insensitive host, a stale import that still spells a renamed directory's old
|
|
14
|
+
// casing names the same file as its authored rewrite, and TypeScript keeps whichever
|
|
15
|
+
// spelling it reaches first; both spellings must read the rewrite.
|
|
16
|
+
const byName = new Map();
|
|
17
|
+
for (const [file, raw] of authored) {
|
|
18
|
+
byName.set(host.getCanonicalFileName(file), raw);
|
|
19
|
+
}
|
|
13
20
|
host.readFile = (file) => {
|
|
14
|
-
const raw =
|
|
21
|
+
const raw = byName.get(host.getCanonicalFileName(resolve(file)));
|
|
15
22
|
if (raw === undefined) {
|
|
16
23
|
return read(file);
|
|
17
24
|
}
|
|
@@ -22,7 +29,7 @@ export function overlayHost(options, authored) {
|
|
|
22
29
|
}
|
|
23
30
|
return raw;
|
|
24
31
|
};
|
|
25
|
-
host.fileExists = (file) =>
|
|
32
|
+
host.fileExists = (file) => byName.has(host.getCanonicalFileName(resolve(file))) || exists(file);
|
|
26
33
|
host.directoryExists = (directory) => {
|
|
27
34
|
for (const file of authored.keys()) {
|
|
28
35
|
if (contains(resolve(directory), file)) {
|
|
@@ -3,13 +3,15 @@ import { readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import { parsePath } from "../../_core/src/path.js";
|
|
6
|
-
import { auditPath, contains, metadata, walk, workspacePaths, markers } from "./files.js";
|
|
6
|
+
import { auditPath, contains, marked, metadata, walk, workspacePaths, markers } from "./files.js";
|
|
7
7
|
import { overlayDirectory, overlayHost } from "./overlay.js";
|
|
8
8
|
import { synchronize, transparent } from "./filesystem.js";
|
|
9
9
|
export function fail(node, message) {
|
|
10
10
|
const source = node.getSourceFile();
|
|
11
11
|
const position = source.getLineAndCharacterOfPosition(node.getStart());
|
|
12
|
-
|
|
12
|
+
// Native, like the generator's other file diagnostics; TypeScript's fileName uses "/" everywhere.
|
|
13
|
+
const file = resolve(source.fileName);
|
|
14
|
+
throw new Error(`${file}:${position.line + 1}:${position.character + 1}: ${message}`);
|
|
13
15
|
}
|
|
14
16
|
export function properties(node, label) {
|
|
15
17
|
if (!ts.isObjectLiteralExpression(node)) {
|
|
@@ -39,7 +41,13 @@ export function properties(node, label) {
|
|
|
39
41
|
return result;
|
|
40
42
|
}
|
|
41
43
|
export function resolveModule(name, source, options, host = ts.sys) {
|
|
42
|
-
|
|
44
|
+
const resolved = ts.resolveModuleName(name, source, options, host).resolvedModule
|
|
45
|
+
?.resolvedFileName;
|
|
46
|
+
// TypeScript separates resolved paths with "/" on every host; callers compare native paths.
|
|
47
|
+
if (resolved === undefined) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return resolve(resolved);
|
|
43
51
|
}
|
|
44
52
|
export function namedImport(source, name, imported) {
|
|
45
53
|
if (!ts.isIdentifier(name)) {
|
|
@@ -259,8 +267,7 @@ export async function discover(workspace, config, scans, out, auto, authored = n
|
|
|
259
267
|
}
|
|
260
268
|
throw new Error(`Cannot read admitted TypeScript source: ${file}`);
|
|
261
269
|
}
|
|
262
|
-
if (file.endsWith(`${sep}route.generated.ts`) &&
|
|
263
|
-
bytes.split("\n", 1)[0] === markers["route.generated.ts"]) {
|
|
270
|
+
if (file.endsWith(`${sep}route.generated.ts`) && marked(bytes, markers["route.generated.ts"])) {
|
|
264
271
|
continue;
|
|
265
272
|
}
|
|
266
273
|
const source = syntaxProgram.getSourceFile(file);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@loozo/angular-router",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.9",
|
|
4
4
|
"description": "Typed route declarations, URLs, navigation and route handles for Angular's native router.",
|
|
5
5
|
"bugs": "https://github.com/theo-matzavinos/loozo/issues",
|
|
6
6
|
"license": "MIT",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// @loozo/angular-router routegen: owned destinations.generated.ts
|
|
2
|
-
import type {
|
|
2
|
+
import type { Signal } from '@angular/core';
|
|
3
|
+
import { injectGeneratedActiveRoute, type RouteSet, type Handle as GeneratedHandle, type Destination as GeneratedDestination } from '@loozo/angular-router/_generated';
|
|
3
4
|
import definition_products_$productId_route from "../src/routes/products/$productId/route.js";
|
|
4
5
|
import definition_reports_$reportId_route from "../src/routes/reports/$reportId/route.js";
|
|
5
6
|
import definition_teams_$teamId_items_$id_route from "../src/routes/teams/$teamId/items/$id/route.js";
|
|
@@ -44,6 +45,29 @@ type ParentOf<Id extends Retained> = Id extends keyof Parents ? Definitions[Pare
|
|
|
44
45
|
export type Handle<Id extends Retained> = GeneratedHandle<Definitions[Id], AbsoluteSelected, ParentOf<Id>, typeof setIdentity, Pick<Definitions, Retained>>;
|
|
45
46
|
/** A same-set nominal reference; use the destination exported by a colocated facade. */
|
|
46
47
|
export type Destination<Id extends Retained> = GeneratedDestination<Definitions[Id], typeof setIdentity, Id>;
|
|
48
|
+
interface ActiveBase {
|
|
49
|
+
/** Exactly this route, not an ancestor. Narrows to that route's handle. */
|
|
50
|
+
is<Other extends Retained>(destination: Destination<Other>): this is ActiveHandle<Other>;
|
|
51
|
+
/** This route or one of its active ancestors. */
|
|
52
|
+
contains(destination: Destination<Retained>): boolean;
|
|
53
|
+
/** Active generated routes, top-level first, ending with this one. */
|
|
54
|
+
readonly chain: readonly ActiveRoute[];
|
|
55
|
+
}
|
|
56
|
+
/** An active route narrowed by is(): that declaration's handle. */
|
|
57
|
+
export interface ActiveHandle<Id extends Retained> extends Handle<Id>, ActiveBase {}
|
|
58
|
+
/** An active route before narrowing: params, search, data and title read as the union of the set's values; link and navigate need is(). */
|
|
59
|
+
export interface ActiveRoute extends Pick<Handle<Retained>, "params" | "search" | "data" | "title">, ActiveBase {}
|
|
60
|
+
/**
|
|
61
|
+
* Injects a signal of the deepest active generated route in the primary outlet.
|
|
62
|
+
*
|
|
63
|
+
* @remarks
|
|
64
|
+
* Call in an Angular injection context. The value changes only when a navigation
|
|
65
|
+
* commits, and is undefined before the first one or when no route of this set is
|
|
66
|
+
* active. A route keeps its handle while it stays active; each call is its own reader.
|
|
67
|
+
*/
|
|
68
|
+
export function injectActiveRoute(): Signal<ActiveRoute | undefined> {
|
|
69
|
+
return injectGeneratedActiveRoute(getRouteSet());
|
|
70
|
+
}
|
|
47
71
|
const routeSet: RouteSet = { records: {
|
|
48
72
|
"src/routes/products/$productId/route.ts": { definition: () => definition_products_$productId_route, owners: ["src/routes/products/$productId/route.ts"] },
|
|
49
73
|
"src/routes/reports/$reportId/route.ts": { definition: () => definition_reports_$reportId_route, owners: ["src/routes/reports/$reportId/route.ts"] },
|
|
@@ -13,8 +13,10 @@ export const destination: Destination<"src/routes/products/$productId/route.ts">
|
|
|
13
13
|
*
|
|
14
14
|
* @remarks
|
|
15
15
|
* Call in an Angular injection context. The handle exposes readonly native
|
|
16
|
-
* params/search/data Signals and destination-typed link/navigate methods.
|
|
17
|
-
* Data
|
|
16
|
+
* params/search/data/title Signals and destination-typed link/navigate methods.
|
|
17
|
+
* Data holds the resolver results of this route and its ancestors, a nearer
|
|
18
|
+
* key hiding an ancestor's; native static data and the title stay out. Title
|
|
19
|
+
* is the nearest declared title in the chain, or undefined when none declares one.
|
|
18
20
|
* All navigation values are explicit; current inputs are never reused implicitly.
|
|
19
21
|
*
|
|
20
22
|
* @returns A noncallable handle for this declaration.
|
|
@@ -13,8 +13,10 @@ export const destination: Destination<"src/routes/reports/$reportId/route.ts"> =
|
|
|
13
13
|
*
|
|
14
14
|
* @remarks
|
|
15
15
|
* Call in an Angular injection context. The handle exposes readonly native
|
|
16
|
-
* params/search/data Signals and destination-typed link/navigate methods.
|
|
17
|
-
* Data
|
|
16
|
+
* params/search/data/title Signals and destination-typed link/navigate methods.
|
|
17
|
+
* Data holds the resolver results of this route and its ancestors, a nearer
|
|
18
|
+
* key hiding an ancestor's; native static data and the title stay out. Title
|
|
19
|
+
* is the nearest declared title in the chain, or undefined when none declares one.
|
|
18
20
|
* All navigation values are explicit; current inputs are never reused implicitly.
|
|
19
21
|
*
|
|
20
22
|
* @returns A noncallable handle for this declaration.
|
|
@@ -10,6 +10,7 @@ export function readInputs() {
|
|
|
10
10
|
|
|
11
11
|
route.params(); // { teamId: number; id: number }
|
|
12
12
|
route.search(); // { score?: number }
|
|
13
|
-
route.data(); // { item: Item },
|
|
13
|
+
route.data(); // { team: { id: number; name: string }; item: Item }, ancestors' resolver results too
|
|
14
|
+
route.title(); // string: this route's title, or the nearest ancestor's
|
|
14
15
|
}
|
|
15
16
|
// #endregion inputs
|
|
@@ -6,8 +6,10 @@ export function itemLinks() {
|
|
|
6
6
|
const route = injectRoute();
|
|
7
7
|
|
|
8
8
|
route.link({ params: { teamId: 7, id: 8 } }); // this route
|
|
9
|
-
route.link("
|
|
10
|
-
route.link("
|
|
11
|
-
route.
|
|
12
|
-
|
|
9
|
+
route.link({ to: "/teams/$teamId/items/$id", params: { teamId: 7, id: 9 } }); // absolute
|
|
10
|
+
route.link({ to: "..", params: { teamId: 7 } }); // parent route
|
|
11
|
+
void route.navigate({ to: report, params: { reportId: 42 } }); // another route's destination
|
|
12
|
+
|
|
13
|
+
// Angular's navigation behavior options go in the same object, for navigate only.
|
|
14
|
+
void route.navigate({ params: { teamId: 7, id: 8 }, search: { score: 10 }, replaceUrl: true });
|
|
13
15
|
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { TestBed } from "@angular/core/testing";
|
|
2
|
+
import { provideRouter } from "@angular/router";
|
|
3
|
+
import { RouterTestingHarness } from "@angular/router/testing";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { routes } from "../../../../../../generated/routes.generated.js";
|
|
6
|
+
import { ItemCatalog, type Item, type ItemQuery } from "../../../../../catalog.js";
|
|
7
|
+
import ItemPage from "./page.js";
|
|
8
|
+
|
|
9
|
+
async function stubCatalog(query: ItemQuery): Promise<Item> {
|
|
10
|
+
return { ...query, name: "Stub item" };
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe("ItemPage", () => {
|
|
14
|
+
it("renders the item from the route's resolvers", async () => {
|
|
15
|
+
TestBed.configureTestingModule({
|
|
16
|
+
providers: [provideRouter(routes), { provide: ItemCatalog, useValue: stubCatalog }],
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const harness = await RouterTestingHarness.create();
|
|
20
|
+
const page = await harness.navigateByUrl("/teams/7/items/3?score=5", ItemPage);
|
|
21
|
+
|
|
22
|
+
expect(page.route.params()).toEqual({ teamId: 7, id: 3 });
|
|
23
|
+
expect(page.route.search()).toEqual({ score: 5 });
|
|
24
|
+
// The parent route's resolver result, merged into this route's data.
|
|
25
|
+
expect(page.route.data().team).toEqual({ id: 7, name: "Team 7" });
|
|
26
|
+
expect(page.route.data().item.name).toBe("Stub item");
|
|
27
|
+
expect(harness.routeNativeElement?.querySelector("h1")?.textContent).toBe("Stub item");
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -13,8 +13,10 @@ export const destination: Destination<"src/routes/teams/$teamId/items/$id/route.
|
|
|
13
13
|
*
|
|
14
14
|
* @remarks
|
|
15
15
|
* Call in an Angular injection context. The handle exposes readonly native
|
|
16
|
-
* params/search/data Signals and destination-typed link/navigate methods.
|
|
17
|
-
* Data
|
|
16
|
+
* params/search/data/title Signals and destination-typed link/navigate methods.
|
|
17
|
+
* Data holds the resolver results of this route and its ancestors, a nearer
|
|
18
|
+
* key hiding an ancestor's; native static data and the title stay out. Title
|
|
19
|
+
* is the nearest declared title in the chain, or undefined when none declares one.
|
|
18
20
|
* All navigation values are explicit; current inputs are never reused implicitly.
|
|
19
21
|
*
|
|
20
22
|
* @returns A noncallable handle for this declaration.
|
|
@@ -13,8 +13,10 @@ export const destination: Destination<"src/routes/teams/$teamId/members/$memberI
|
|
|
13
13
|
*
|
|
14
14
|
* @remarks
|
|
15
15
|
* Call in an Angular injection context. The handle exposes readonly native
|
|
16
|
-
* params/search/data Signals and destination-typed link/navigate methods.
|
|
17
|
-
* Data
|
|
16
|
+
* params/search/data/title Signals and destination-typed link/navigate methods.
|
|
17
|
+
* Data holds the resolver results of this route and its ancestors, a nearer
|
|
18
|
+
* key hiding an ancestor's; native static data and the title stay out. Title
|
|
19
|
+
* is the nearest declared title in the chain, or undefined when none declares one.
|
|
18
20
|
* All navigation values are explicit; current inputs are never reused implicitly.
|
|
19
21
|
*
|
|
20
22
|
* @returns A noncallable handle for this declaration.
|
|
@@ -13,8 +13,10 @@ export const destination: Destination<"src/routes/teams/$teamId/route.ts"> = /*
|
|
|
13
13
|
*
|
|
14
14
|
* @remarks
|
|
15
15
|
* Call in an Angular injection context. The handle exposes readonly native
|
|
16
|
-
* params/search/data Signals and destination-typed link/navigate methods.
|
|
17
|
-
* Data
|
|
16
|
+
* params/search/data/title Signals and destination-typed link/navigate methods.
|
|
17
|
+
* Data holds the resolver results of this route and its ancestors, a nearer
|
|
18
|
+
* key hiding an ancestor's; native static data and the title stay out. Title
|
|
19
|
+
* is the nearest declared title in the chain, or undefined when none declares one.
|
|
18
20
|
* All navigation values are explicit; current inputs are never reused implicitly.
|
|
19
21
|
*
|
|
20
22
|
* @returns A noncallable handle for this declaration.
|
|
@@ -3,4 +3,7 @@ import { defineRoute, numberParam } from "@loozo/angular-router";
|
|
|
3
3
|
export default defineRoute("teams/$teamId", {
|
|
4
4
|
params: { teamId: numberParam },
|
|
5
5
|
search: { score: numberParam },
|
|
6
|
+
resolve: {
|
|
7
|
+
team: ({ params }) => ({ id: params.teamId, name: `Team ${params.teamId}` }),
|
|
8
|
+
},
|
|
6
9
|
});
|
package/snippets/src/shell.ts
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
|
-
import { Component } from "@angular/core";
|
|
1
|
+
import { Component, computed } from "@angular/core";
|
|
2
2
|
import { RouterOutlet } from "@angular/router";
|
|
3
|
+
import { injectActiveRoute } from "../generated/destinations.generated.js";
|
|
4
|
+
import { destination as team } from "./routes/teams/$teamId/route.generated.js";
|
|
5
|
+
import { destination as item } from "./routes/teams/$teamId/items/$id/route.generated.js";
|
|
3
6
|
|
|
4
7
|
@Component({
|
|
5
8
|
selector: "app-shell",
|
|
6
9
|
imports: [RouterOutlet],
|
|
7
|
-
template:
|
|
10
|
+
template: `
|
|
11
|
+
@if (teamId() !== undefined) {
|
|
12
|
+
<nav>Team {{ teamId() }} <button (click)="next()">Next item</button></nav>
|
|
13
|
+
}
|
|
14
|
+
<router-outlet />
|
|
15
|
+
`,
|
|
8
16
|
})
|
|
9
|
-
export default class Shell {
|
|
17
|
+
export default class Shell {
|
|
18
|
+
// The deepest active route, or undefined before the first navigation.
|
|
19
|
+
readonly active = injectActiveRoute();
|
|
20
|
+
|
|
21
|
+
// An ancestor's params: number | undefined.
|
|
22
|
+
readonly teamId = computed(
|
|
23
|
+
() =>
|
|
24
|
+
this.active()
|
|
25
|
+
?.chain.find((route) => route.is(team))
|
|
26
|
+
?.params().teamId,
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
// Before narrowing, params, search and data read as a union over every route.
|
|
30
|
+
readonly params = computed(() => this.active()?.params());
|
|
31
|
+
|
|
32
|
+
// Whether this route or one of its ancestors is the team route.
|
|
33
|
+
readonly inTeam = computed(() => this.active()?.contains(team) ?? false);
|
|
34
|
+
|
|
35
|
+
next() {
|
|
36
|
+
const route = this.active();
|
|
37
|
+
|
|
38
|
+
// is() narrows to the item's handle, so link and navigate are typed for it.
|
|
39
|
+
if (route?.is(item)) {
|
|
40
|
+
const { teamId, id } = route.params();
|
|
41
|
+
|
|
42
|
+
void route.navigate({ params: { teamId, id: id + 1 }, replaceUrl: true });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|