@terpjs/contract 0.8.0 → 0.10.0
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/openapi.json +8 -0
- package/package.json +1 -1
- package/scripts/build-tokens.mjs +61 -1
- package/src/appearance-mechanism.js +19 -0
- package/src/icons.ts +98 -0
- package/src/index.ts +3 -1
- package/src/manifest.ts +128 -2
- package/src/routes-codegen.js +100 -32
- package/src/routes-codegen.test.js +92 -15
- package/src/schema.d.ts +5 -0
- package/src/tokens.contrast.test.js +16 -5
- package/src/tokens.css +23 -0
- package/src/tokens.manifest.json +104 -5
- package/src/tokens.manifest.test.js +49 -2
- package/src/tokens.themes.test.js +22 -1
- package/token-pairs.json +22 -1
- package/tokens.json +19 -0
package/openapi.json
CHANGED
|
@@ -104,6 +104,14 @@
|
|
|
104
104
|
"title": "Id",
|
|
105
105
|
"type": "string"
|
|
106
106
|
},
|
|
107
|
+
"permissions": {
|
|
108
|
+
"default": [],
|
|
109
|
+
"items": {
|
|
110
|
+
"type": "string"
|
|
111
|
+
},
|
|
112
|
+
"title": "Permissions",
|
|
113
|
+
"type": "array"
|
|
114
|
+
},
|
|
107
115
|
"role_name": {
|
|
108
116
|
"title": "Role Name",
|
|
109
117
|
"type": "string"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@terpjs/contract",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Terp frontend contract \u2014 the OpenAPI-generated TypeScript client, design tokens, and the stack-agnostic module/route/nav + auth types.",
|
|
6
6
|
"exports": {
|
package/scripts/build-tokens.mjs
CHANGED
|
@@ -31,6 +31,23 @@ const buildDir = mkdtempSync(join(tmpdir(), "terp-tokens-"));
|
|
|
31
31
|
|
|
32
32
|
const read = (name) => JSON.parse(readFileSync(join(packageRoot, name), "utf8"));
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* WCAG 2.1 AA for normal-size text — the floor every theme is held to unless `themes.json`
|
|
36
|
+
* raises it. Declared here because the manifest publishes an effective floor per theme and
|
|
37
|
+
* the contrast gate reads it back; the constant has one home, on the writing side.
|
|
38
|
+
*/
|
|
39
|
+
const AA_NORMAL_TEXT = 4.5;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* WCAG 2.1 SC 1.4.11, non-text contrast: the bar for a control's visual boundary and for a
|
|
43
|
+
* state or focus indicator. Published once at the top level rather than per theme, and that is
|
|
44
|
+
* the claim: it is FLAT across every theme, including the one that raises its text floor to
|
|
45
|
+
* AAA, because WCAG defines no AAA tier for non-text contrast. A consumer holding the
|
|
46
|
+
* `nonTextPairs` section to a theme's raised text floor would be enforcing a standard nobody
|
|
47
|
+
* wrote; one holding it to nothing would read the section as decorative.
|
|
48
|
+
*/
|
|
49
|
+
const UI_COMPONENT = 3;
|
|
50
|
+
|
|
34
51
|
const registry = read("themes.json");
|
|
35
52
|
const themes = registry.themes;
|
|
36
53
|
const base = themes.find((theme) => theme.name === registry.base);
|
|
@@ -94,6 +111,34 @@ for (const theme of themes) {
|
|
|
94
111
|
}
|
|
95
112
|
rmSync(buildDir, { recursive: true, force: true });
|
|
96
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The appearance switch: the one theme fact CSS can consume but cannot select on.
|
|
116
|
+
*
|
|
117
|
+
* `color-scheme` already records whether a theme reads light or dark, and it is the right
|
|
118
|
+
* answer for native chrome — but there is no selector for it, so nothing in a stylesheet can
|
|
119
|
+
* branch on it. Anything that must render one way under a light theme and another under a dark
|
|
120
|
+
* one therefore needs the same fact in custom-property form. Its first consumer is `AppShell`'s
|
|
121
|
+
* brand mark: the bundled icons all stroke in `currentColor`, but a company logo usually cannot,
|
|
122
|
+
* and a dark-ink one is invisible on three of the five shipped themes.
|
|
123
|
+
*
|
|
124
|
+
* Derived rather than declared per theme, and that is the whole reason it lives here. A
|
|
125
|
+
* stylesheet enumerating which themes are dark is a list that rots the first time one is added,
|
|
126
|
+
* silently — the wrong mark, on the new theme only. `appearance` is required by `themes.json`,
|
|
127
|
+
* validated above, and named in the registry's own comment as one of the four things a theme
|
|
128
|
+
* file cannot imply. So a sixth theme cannot forget to answer.
|
|
129
|
+
*
|
|
130
|
+
* Values are `block` / `none` rather than any particular layout keyword: a consumer centres
|
|
131
|
+
* from the BOX around the thing it is showing, so a theme never has to know what layout that
|
|
132
|
+
* consumer uses.
|
|
133
|
+
*
|
|
134
|
+
* Deliberately NOT in the token manifest, and the two gates that had to be widened for it say
|
|
135
|
+
* why in their own files: it is a mechanism, not a design knob, and a theme editor offering
|
|
136
|
+
* `block` / `none` as an editable pair offers a way to break the switch.
|
|
137
|
+
*/
|
|
138
|
+
const appearanceSwitch = (appearance) =>
|
|
139
|
+
` --appearance-show-light: ${appearance === "light" ? "block" : "none"};
|
|
140
|
+
--appearance-show-dark: ${appearance === "dark" ? "block" : "none"};`;
|
|
141
|
+
|
|
97
142
|
const themeBlocks = overlays
|
|
98
143
|
.map(
|
|
99
144
|
(theme) => `
|
|
@@ -101,6 +146,7 @@ const themeBlocks = overlays
|
|
|
101
146
|
${theme.description} */
|
|
102
147
|
[data-theme='${theme.name}'] {
|
|
103
148
|
color-scheme: ${theme.appearance};
|
|
149
|
+
${appearanceSwitch(theme.appearance)}
|
|
104
150
|
${compiled.get(theme.name)}
|
|
105
151
|
}
|
|
106
152
|
`,
|
|
@@ -118,6 +164,9 @@ const output = `/**
|
|
|
118
164
|
text-field carets) into the ${base.appearance} palette so it never renders as foreign
|
|
119
165
|
OS-${base.appearance === "light" ? "dark" : "light"} chrome. Each theme block below sets its own. */
|
|
120
166
|
color-scheme: ${base.appearance};
|
|
167
|
+
/* The appearance as something a stylesheet can branch on — see appearanceSwitch above.
|
|
168
|
+
color-scheme records the same fact and no selector can read it. */
|
|
169
|
+
${appearanceSwitch(base.appearance)}
|
|
121
170
|
${compiled.get(base.name)}
|
|
122
171
|
}
|
|
123
172
|
${themeBlocks}
|
|
@@ -125,6 +174,7 @@ ${themeBlocks}
|
|
|
125
174
|
@media (prefers-color-scheme: dark) {
|
|
126
175
|
:root:not([data-theme]) {
|
|
127
176
|
color-scheme: ${systemDark.appearance};
|
|
177
|
+
${appearanceSwitch(systemDark.appearance).replace(/^ {2}/gm, " ")}
|
|
128
178
|
${compiled.get(systemDark.name).replace(/^ {2}/gm, " ")}
|
|
129
179
|
}
|
|
130
180
|
}
|
|
@@ -183,11 +233,18 @@ const manifest = {
|
|
|
183
233
|
// alone rather than hard-coding the list it happens to know about.
|
|
184
234
|
base: registry.base,
|
|
185
235
|
systemDark: registry.systemDark,
|
|
186
|
-
themes: themes.map(({ name, label, appearance, description }) => ({
|
|
236
|
+
themes: themes.map(({ name, label, appearance, description, minimumContrast }) => ({
|
|
187
237
|
name,
|
|
188
238
|
label,
|
|
189
239
|
appearance,
|
|
190
240
|
description,
|
|
241
|
+
// The ratio this theme's declared text pairings must reach, published as a NUMBER on every
|
|
242
|
+
// theme rather than only on the one that raises it. A consumer that saw the field only on
|
|
243
|
+
// `contrast` would have to know WCAG's AA constant to interpret its absence, and would then
|
|
244
|
+
// be free to interpret it differently from the gate — which is the one thing this file
|
|
245
|
+
// exists to prevent. `tokens.contrast.test.js` reads this value instead of re-deriving it,
|
|
246
|
+
// so the published floor IS the enforced floor.
|
|
247
|
+
minimumContrast: minimumContrast ?? AA_NORMAL_TEXT,
|
|
191
248
|
})),
|
|
192
249
|
tokens: [...baseTokens.entries()].map(([name, token]) => ({
|
|
193
250
|
name,
|
|
@@ -204,6 +261,9 @@ const manifest = {
|
|
|
204
261
|
),
|
|
205
262
|
themeable: overlays.some((theme) => sources.get(theme.name).has(name)),
|
|
206
263
|
})),
|
|
264
|
+
// The floor for the `nonTextPairs` section below — flat across themes, unlike the per-theme
|
|
265
|
+
// text floor above. Named at the top level so the two sections cannot be read as sharing a bar.
|
|
266
|
+
nonTextMinimumContrast: UI_COMPONENT,
|
|
207
267
|
textPairs: pairs.textPairs,
|
|
208
268
|
// Both sections, because a consumer that can only see the text pairings would read the
|
|
209
269
|
// absence of a boundary pairing as "no requirement" rather than "held elsewhere".
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-theme custom properties that are NOT design tokens, as an exact list.
|
|
3
|
+
*
|
|
4
|
+
* Two gates are stated over "non-colour" tokens and mean it: geometry is theme-invariant
|
|
5
|
+
* (`tokens.themes.test.js`), and the manifest names every token the base root declares
|
|
6
|
+
* (`tokens.manifest.test.js`). This pair is neither — it is the theme's own `appearance` in
|
|
7
|
+
* the one form a stylesheet can branch on, because `color-scheme` records the same fact and no
|
|
8
|
+
* selector can read it. The values are `block` / `none`, so it varies per theme by
|
|
9
|
+
* construction and belongs in no theme editor.
|
|
10
|
+
*
|
|
11
|
+
* Hand-written rather than imported from the generator that emits it, and that is the point:
|
|
12
|
+
* a third such property appearing in `tokens.css` fails both gates until someone adds it here
|
|
13
|
+
* with a reason. Importing the generator's own list would make every future addition
|
|
14
|
+
* self-approving.
|
|
15
|
+
*/
|
|
16
|
+
export const APPEARANCE_MECHANISM_TOKENS = [
|
|
17
|
+
"--appearance-show-light",
|
|
18
|
+
"--appearance-show-dark",
|
|
19
|
+
];
|
package/src/icons.ts
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bundled icon names, as data the contract publishes.
|
|
3
|
+
*
|
|
4
|
+
* Contract cannot import react-core — the dependency runs the other way — so the glyphs
|
|
5
|
+
* themselves stay in react-core and only their NAMES live here. That split is what lets
|
|
6
|
+
* `NavItem.icon` be a checked name on a manifest that knows nothing about React: an app naming
|
|
7
|
+
* an icon this set does not contain gets a typecheck error where it used to get a silent letter
|
|
8
|
+
* tile, and a future non-React adapter reads the same list.
|
|
9
|
+
*
|
|
10
|
+
* `as const` is load-bearing rather than stylistic. Without it the array widens to `string[]`,
|
|
11
|
+
* {@link IconName} becomes `string`, and every check built on it still compiles while asserting
|
|
12
|
+
* nothing at all — the whole change would ship and do nothing. That is what the
|
|
13
|
+
* `@ts-expect-error` guard beside the glyph table in react-core exists to catch: remove the
|
|
14
|
+
* `as const` here and the directive there becomes unused, which is itself an error.
|
|
15
|
+
*
|
|
16
|
+
* Kept in the glyph table's declaration order rather than sorted, so the two read as one list.
|
|
17
|
+
* react-core holds the table to this set exhaustively in both directions with `satisfies`, so a
|
|
18
|
+
* glyph added without a name here, or a name added without a glyph, is a compile error at the
|
|
19
|
+
* table rather than a runtime blank.
|
|
20
|
+
*/
|
|
21
|
+
export const ICON_NAMES = [
|
|
22
|
+
"home",
|
|
23
|
+
"list",
|
|
24
|
+
"folder",
|
|
25
|
+
"users",
|
|
26
|
+
"shield",
|
|
27
|
+
"settings",
|
|
28
|
+
"sun",
|
|
29
|
+
"moon",
|
|
30
|
+
"monitor",
|
|
31
|
+
"moon-stars",
|
|
32
|
+
"sunset",
|
|
33
|
+
"contrast",
|
|
34
|
+
"document",
|
|
35
|
+
"chart",
|
|
36
|
+
"calendar",
|
|
37
|
+
"inbox",
|
|
38
|
+
"audit",
|
|
39
|
+
"hub",
|
|
40
|
+
"plus",
|
|
41
|
+
"edit",
|
|
42
|
+
"trash",
|
|
43
|
+
"search",
|
|
44
|
+
"check",
|
|
45
|
+
"x",
|
|
46
|
+
"chevron-down",
|
|
47
|
+
"chevron-right",
|
|
48
|
+
"chevron-left",
|
|
49
|
+
"arrow-left",
|
|
50
|
+
"external",
|
|
51
|
+
"logout",
|
|
52
|
+
"user",
|
|
53
|
+
"bell",
|
|
54
|
+
"key",
|
|
55
|
+
"globe",
|
|
56
|
+
"lock",
|
|
57
|
+
"tag",
|
|
58
|
+
"mail",
|
|
59
|
+
"refresh",
|
|
60
|
+
"filter",
|
|
61
|
+
"download",
|
|
62
|
+
"upload",
|
|
63
|
+
"star",
|
|
64
|
+
"heart",
|
|
65
|
+
"database",
|
|
66
|
+
"code",
|
|
67
|
+
"truck",
|
|
68
|
+
"cart",
|
|
69
|
+
"wallet",
|
|
70
|
+
"map-pin",
|
|
71
|
+
"clock",
|
|
72
|
+
"link",
|
|
73
|
+
"grid",
|
|
74
|
+
"book",
|
|
75
|
+
"briefcase",
|
|
76
|
+
"building",
|
|
77
|
+
"clipboard",
|
|
78
|
+
"layers",
|
|
79
|
+
"send",
|
|
80
|
+
"phone",
|
|
81
|
+
"image",
|
|
82
|
+
"video",
|
|
83
|
+
"music",
|
|
84
|
+
"wrench",
|
|
85
|
+
"zap",
|
|
86
|
+
"eye",
|
|
87
|
+
"eye-off",
|
|
88
|
+
] as const;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A name {@link ICON_NAMES} contains — the type `NavItem.icon` and `Icon` accept.
|
|
92
|
+
*
|
|
93
|
+
* Deliberately NOT the type of `NavIcon.name`, which stays `string`. `NavIcon` falls back to the
|
|
94
|
+
* label's initial in a tile, so an unknown name there is a designed, visible behaviour with a
|
|
95
|
+
* specimen of its own; `Icon` renders nothing at all, which is silence, and silence is the thing
|
|
96
|
+
* this type exists to make impossible.
|
|
97
|
+
*/
|
|
98
|
+
export type IconName = (typeof ICON_NAMES)[number];
|
package/src/index.ts
CHANGED
|
@@ -5,5 +5,7 @@ export type { components, operations, paths } from "./schema";
|
|
|
5
5
|
|
|
6
6
|
// Stack-agnostic UI contract: the module/route/nav manifest and the auth/session shape.
|
|
7
7
|
export { defineModuleManifest } from "./manifest";
|
|
8
|
-
export
|
|
8
|
+
export { ICON_NAMES } from "./icons";
|
|
9
|
+
export type { IconName } from "./icons";
|
|
10
|
+
export type { ModuleManifest, ModuleRoute, NavGroup, NavItem, RoleName } from "./manifest";
|
|
9
11
|
export type { AccessToken, Action, AuthSession, Credentials, CurrentUser } from "./auth";
|
package/src/manifest.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
/** A role name as understood by the app's backend (e.g. "viewer" | "editor" | "admin"). */
|
|
13
|
+
import type { IconName } from "./icons";
|
|
14
|
+
|
|
13
15
|
export type RoleName = string;
|
|
14
16
|
|
|
15
17
|
export interface ModuleRoute {
|
|
@@ -26,6 +28,39 @@ export interface ModuleRoute {
|
|
|
26
28
|
view: string;
|
|
27
29
|
/** Minimum role required to see the route; omitted = any authenticated user. */
|
|
28
30
|
role?: RoleName;
|
|
31
|
+
/**
|
|
32
|
+
* Also require this named permission grant — the caller must hold it in
|
|
33
|
+
* `CurrentUser.permissions`.
|
|
34
|
+
*
|
|
35
|
+
* **ANDed with `role`**, and that is deliberately what the server does: a `Policy` carrying a
|
|
36
|
+
* `Permission` enforces the permission's role floor *and* the grant, so a client checking
|
|
37
|
+
* only one would disagree with the endpoint in one direction or the other. The same reasoning
|
|
38
|
+
* the `Authorized` component's `permission` prop already records.
|
|
39
|
+
*
|
|
40
|
+
* Deliberately not a combinator. The server's own declaration is one ref per read and one per
|
|
41
|
+
* write (`AuthzRef = Role | Permission | Roles`), so an any-of here could express a gate no
|
|
42
|
+
* `Policy` can declare — and a client gate that cannot correspond to a server gate can only
|
|
43
|
+
* drift from the endpoint it mirrors.
|
|
44
|
+
*
|
|
45
|
+
* A *display* and *routing* gate only; the server re-checks every request. Fails closed:
|
|
46
|
+
* unknown or misspelled names are simply absent from the grant list, and an app that mounts
|
|
47
|
+
* no grant capability has an empty list, which correctly hides everything that names one.
|
|
48
|
+
*/
|
|
49
|
+
permission?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Query-string keys this route reads, e.g. `["status", "page"]`.
|
|
52
|
+
*
|
|
53
|
+
* Declared for the same reason params are: the router is realised at runtime, so
|
|
54
|
+
* nothing checks a search key either — and a list screen's filters live in the query
|
|
55
|
+
* string, which is why *most* screens were the ones bypassing the typed navigation
|
|
56
|
+
* seam entirely. `terp routes` emits these into the generated table, so navigating
|
|
57
|
+
* with an undeclared key (or reading one) is a typecheck error.
|
|
58
|
+
*
|
|
59
|
+
* Values are `string | undefined` and nothing more: a query parameter is text, and
|
|
60
|
+
* every key is absent until someone sets it. Parsing `page` into a number is the
|
|
61
|
+
* screen's business — declaring the key is what stops it being a typo.
|
|
62
|
+
*/
|
|
63
|
+
search?: string[];
|
|
29
64
|
}
|
|
30
65
|
|
|
31
66
|
export interface NavItem {
|
|
@@ -33,10 +68,101 @@ export interface NavItem {
|
|
|
33
68
|
label: string;
|
|
34
69
|
/** Destination path; should match a {@link ModuleRoute.path}. */
|
|
35
70
|
to: string;
|
|
36
|
-
/**
|
|
37
|
-
|
|
71
|
+
/**
|
|
72
|
+
* Which bundled glyph the item shows, by name.
|
|
73
|
+
*
|
|
74
|
+
* A **checked** name ({@link IconName}), which is the one deliberately breaking part of the
|
|
75
|
+
* navigation model: a misspelled icon used to render the label's first letter in a tile, which
|
|
76
|
+
* looks like a considered fallback rather than like a typo, so it survived review and shipped.
|
|
77
|
+
* It is now a typecheck error at the manifest. Runtime behaviour is unchanged — the fallback
|
|
78
|
+
* still exists and still handles the honest case of an item with no icon at all.
|
|
79
|
+
*/
|
|
80
|
+
icon?: IconName;
|
|
38
81
|
/** Minimum role required to show the nav item. */
|
|
39
82
|
role?: RoleName;
|
|
83
|
+
/**
|
|
84
|
+
* Also require this named permission grant — the caller must hold it in
|
|
85
|
+
* `CurrentUser.permissions`.
|
|
86
|
+
*
|
|
87
|
+
* **ANDed with `role`**, and that is deliberately what the server does: a `Policy` carrying a
|
|
88
|
+
* `Permission` enforces the permission's role floor *and* the grant, so a client checking
|
|
89
|
+
* only one would disagree with the endpoint in one direction or the other. The same reasoning
|
|
90
|
+
* the `Authorized` component's `permission` prop already records.
|
|
91
|
+
*
|
|
92
|
+
* Deliberately not a combinator. The server's own declaration is one ref per read and one per
|
|
93
|
+
* write (`AuthzRef = Role | Permission | Roles`), so an any-of here could express a gate no
|
|
94
|
+
* `Policy` can declare — and a client gate that cannot correspond to a server gate can only
|
|
95
|
+
* drift from the endpoint it mirrors.
|
|
96
|
+
*
|
|
97
|
+
* A *display* and *routing* gate only; the server re-checks every request. Fails closed:
|
|
98
|
+
* unknown or misspelled names are simply absent from the grant list, and an app that mounts
|
|
99
|
+
* no grant capability has an empty list, which correctly hides everything that names one.
|
|
100
|
+
*/
|
|
101
|
+
permission?: string;
|
|
102
|
+
/**
|
|
103
|
+
* Match the URL exactly rather than as a segment-aligned prefix.
|
|
104
|
+
*
|
|
105
|
+
* The default is the prefix, and that is the useful behaviour: a detail page under a section
|
|
106
|
+
* keeps the section's tab lit, so `/records/123` leaves "Records" current. Set this where a
|
|
107
|
+
* destination should own only itself — typically a landing page that also has children in the
|
|
108
|
+
* nav, where the parent would otherwise stay lit on every child.
|
|
109
|
+
*
|
|
110
|
+
* It does not decide WHICH item is current when several match; that is a property of the set,
|
|
111
|
+
* and the adapter resolves it by longest match. This only says whether this item is a
|
|
112
|
+
* candidate at all.
|
|
113
|
+
*/
|
|
114
|
+
exact?: boolean;
|
|
115
|
+
/**
|
|
116
|
+
* The {@link NavGroup} this item belongs to, by id.
|
|
117
|
+
*
|
|
118
|
+
* An item naming a group the app has not declared falls into the default headerless group
|
|
119
|
+
* rather than disappearing, and that is the deliberate direction to fail. A group is declared
|
|
120
|
+
* once by the **app**; the item is declared by a **module** that ships on its own schedule, so
|
|
121
|
+
* an id with no declaration yet is the normal first-run state of a module the app has not
|
|
122
|
+
* finished adopting. Silently dropping the link would hide a working screen and report nothing.
|
|
123
|
+
*/
|
|
124
|
+
group?: string;
|
|
125
|
+
/**
|
|
126
|
+
* Sort key against the item's siblings inside its group.
|
|
127
|
+
*
|
|
128
|
+
* Absent is 0, so a positive number sorts below every unordered sibling and a negative one
|
|
129
|
+
* above — CSS `order` semantics, which is the vocabulary this framework already speaks.
|
|
130
|
+
* The sort is stable, so items that tie keep their declaration order and a manifest that
|
|
131
|
+
* declares no order anywhere renders exactly as it does today.
|
|
132
|
+
*/
|
|
133
|
+
order?: number;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A named section of the primary navigation, declared once by the **app**.
|
|
138
|
+
*
|
|
139
|
+
* A group spans modules — a "Sales" group holds items contributed by several of them — so no
|
|
140
|
+
* module can own its label or its position, and it is the one part of the navigation model that
|
|
141
|
+
* cannot live on a module manifest. Items reference it by {@link NavItem.group}.
|
|
142
|
+
*
|
|
143
|
+
* Declaring groups is optional and additive: an app that declares none renders one flat,
|
|
144
|
+
* unlabelled list, which is what every app renders today.
|
|
145
|
+
*/
|
|
146
|
+
export interface NavGroup {
|
|
147
|
+
/** Referenced by {@link NavItem.group}. */
|
|
148
|
+
id: string;
|
|
149
|
+
/**
|
|
150
|
+
* Rendered above the group's list.
|
|
151
|
+
*
|
|
152
|
+
* `null` renders **no label element at all** — a positioning-only group, which is how an app
|
|
153
|
+
* places its otherwise-ungrouped items somewhere other than the end without inventing a
|
|
154
|
+
* heading for them. Required rather than optional so that "no label" is a decision the
|
|
155
|
+
* declaration states, not an omission.
|
|
156
|
+
*/
|
|
157
|
+
label: string | null;
|
|
158
|
+
/**
|
|
159
|
+
* Sort key against sibling groups.
|
|
160
|
+
*
|
|
161
|
+
* Absent is 0 and the sort is stable, so groups that tie keep declaration order. The default
|
|
162
|
+
* headerless group is **not** part of this sort: it is always emitted last. See
|
|
163
|
+
* `groupNav` in `@terpjs/react-core` for why.
|
|
164
|
+
*/
|
|
165
|
+
order?: number;
|
|
40
166
|
}
|
|
41
167
|
|
|
42
168
|
export interface ModuleManifest {
|
package/src/routes-codegen.js
CHANGED
|
@@ -35,9 +35,10 @@ import ts from "typescript";
|
|
|
35
35
|
const HEADER = [
|
|
36
36
|
"// Generated by `terp routes` from this app's module manifests. Do not edit.",
|
|
37
37
|
"//",
|
|
38
|
-
"// Maps every route path the manifests declare to that route's params
|
|
39
|
-
"// useRouteParams / useRouteParam /
|
|
40
|
-
"//
|
|
38
|
+
"// Maps every route path the manifests declare to that route's params and its declared",
|
|
39
|
+
"// query-string keys, so useRouteParams / useRouteParam / useRouteSearch /",
|
|
40
|
+
"// useTerpNavigate check paths, param names and search keys at compile time",
|
|
41
|
+
"// (ADR 0092, ADR 0096). Regenerate after changing a manifest route — `terp verify`",
|
|
41
42
|
"// fails on a stale copy. Routes mounted by a packaged area (the admin area) are not",
|
|
42
43
|
"// keyed here: this file is a pure function of this app's own manifests.",
|
|
43
44
|
"",
|
|
@@ -57,15 +58,29 @@ export function canonicalPath(routePath) {
|
|
|
57
58
|
return routePath.replace(/(^|\/)\$([A-Za-z_][A-Za-z0-9_]*)/g, "$1:$2");
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/** Find a property assignment by name on an object literal, or undefined. */
|
|
62
|
+
function propertyNamed(element, name) {
|
|
63
|
+
return element.properties.find(
|
|
64
|
+
(property) =>
|
|
65
|
+
ts.isPropertyAssignment(property) &&
|
|
66
|
+
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
67
|
+
property.name.text === name,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
60
71
|
/**
|
|
61
|
-
* Every route
|
|
72
|
+
* Every route a source file's `defineModuleManifest(...)` calls declare.
|
|
62
73
|
*
|
|
63
|
-
* Returns `{
|
|
64
|
-
* `problems` (with file:line) rather than being
|
|
74
|
+
* Returns `{ routes, problems }`, where a route is `{ path, search }`. A shape that
|
|
75
|
+
* cannot be read statically lands in `problems` (with file:line) rather than being
|
|
76
|
+
* skipped — the caller refuses on any. `search` is held to the same standard as `path`:
|
|
77
|
+
* a computed key list is refused rather than silently dropped, because a missing search
|
|
78
|
+
* key turns a real navigation into a type error, which is the failure mode that teaches
|
|
79
|
+
* authors to distrust the check.
|
|
65
80
|
*/
|
|
66
|
-
export function
|
|
81
|
+
export function extractRoutes(sourceText, label) {
|
|
67
82
|
const source = ts.createSourceFile(label, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
68
|
-
const
|
|
83
|
+
const routes = [];
|
|
69
84
|
const problems = [];
|
|
70
85
|
let manifests = 0;
|
|
71
86
|
|
|
@@ -90,12 +105,7 @@ export function extractRoutePaths(sourceText, label) {
|
|
|
90
105
|
);
|
|
91
106
|
continue;
|
|
92
107
|
}
|
|
93
|
-
const pathProperty = element
|
|
94
|
-
(property) =>
|
|
95
|
-
ts.isPropertyAssignment(property) &&
|
|
96
|
-
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
97
|
-
property.name.text === "path",
|
|
98
|
-
);
|
|
108
|
+
const pathProperty = propertyNamed(element, "path");
|
|
99
109
|
if (pathProperty === undefined) {
|
|
100
110
|
problems.push(`${at(element)}: a route declares no \`path\`.`);
|
|
101
111
|
continue;
|
|
@@ -108,8 +118,48 @@ export function extractRoutePaths(sourceText, label) {
|
|
|
108
118
|
);
|
|
109
119
|
continue;
|
|
110
120
|
}
|
|
111
|
-
|
|
121
|
+
const search = readSearchKeys(element);
|
|
122
|
+
if (search === null) {
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
routes.push({ path: value.text, search });
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** A route's declared query-string keys, `[]` when it declares none, `null` on refusal. */
|
|
130
|
+
const readSearchKeys = (element) => {
|
|
131
|
+
const searchProperty = propertyNamed(element, "search");
|
|
132
|
+
if (searchProperty === undefined) {
|
|
133
|
+
return [];
|
|
134
|
+
}
|
|
135
|
+
const value = searchProperty.initializer;
|
|
136
|
+
if (!ts.isArrayLiteralExpression(value)) {
|
|
137
|
+
problems.push(
|
|
138
|
+
`${at(value)}: a route \`search\` is not an array literal, so its keys cannot be read ` +
|
|
139
|
+
'statically. Write them inline (e.g. search: ["status", "page"]).',
|
|
140
|
+
);
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
const keys = [];
|
|
144
|
+
for (const key of value.elements) {
|
|
145
|
+
if (!ts.isStringLiteral(key)) {
|
|
146
|
+
problems.push(
|
|
147
|
+
`${at(key)}: a route \`search\` key is not a plain string literal, so it cannot be ` +
|
|
148
|
+
'read statically. Write each key inline (e.g. search: ["status", "page"]).',
|
|
149
|
+
);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key.text)) {
|
|
153
|
+
problems.push(
|
|
154
|
+
`${at(key)}: search key ${JSON.stringify(key.text)} is not a plain identifier, so it ` +
|
|
155
|
+
"cannot be emitted as a typed property. Rename it to letters, digits and " +
|
|
156
|
+
"underscores.",
|
|
157
|
+
);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
keys.push(key.text);
|
|
112
161
|
}
|
|
162
|
+
return keys;
|
|
113
163
|
};
|
|
114
164
|
|
|
115
165
|
const visit = (node) => {
|
|
@@ -127,16 +177,11 @@ export function extractRoutePaths(sourceText, label) {
|
|
|
127
177
|
"cannot be read statically.",
|
|
128
178
|
);
|
|
129
179
|
} else {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
ts.isPropertyAssignment(property) &&
|
|
133
|
-
(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) &&
|
|
134
|
-
property.name.text === "routes",
|
|
135
|
-
);
|
|
136
|
-
if (routes === undefined) {
|
|
180
|
+
const declared = propertyNamed(argument, "routes");
|
|
181
|
+
if (declared === undefined) {
|
|
137
182
|
problems.push(`${at(argument)}: the manifest declares no \`routes\`.`);
|
|
138
183
|
} else {
|
|
139
|
-
readRoutesArray(
|
|
184
|
+
readRoutesArray(declared.initializer);
|
|
140
185
|
}
|
|
141
186
|
}
|
|
142
187
|
}
|
|
@@ -150,7 +195,7 @@ export function extractRoutePaths(sourceText, label) {
|
|
|
150
195
|
"a module whose manifest is built elsewhere cannot be extracted.",
|
|
151
196
|
);
|
|
152
197
|
}
|
|
153
|
-
return {
|
|
198
|
+
return { routes, problems };
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
/** The module files to scan: `<modulesDir>/<name>/module.tsx` (or `.ts`), sorted. */
|
|
@@ -174,9 +219,18 @@ export function moduleFiles(modulesDir) {
|
|
|
174
219
|
return found;
|
|
175
220
|
}
|
|
176
221
|
|
|
177
|
-
/** Render the declaration file for *
|
|
178
|
-
export function renderRouteTable(
|
|
179
|
-
|
|
222
|
+
/** Render the declaration file for *routes* — deterministic: deduped, sorted, LF. */
|
|
223
|
+
export function renderRouteTable(routes) {
|
|
224
|
+
// Two manifests may mount the same path; their declared search keys are unioned, which
|
|
225
|
+
// is the honest reading of "this route reads these keys".
|
|
226
|
+
const searchByPath = new Map();
|
|
227
|
+
for (const route of routes) {
|
|
228
|
+
const key = canonicalPath(route.path);
|
|
229
|
+
const merged = searchByPath.get(key) ?? [];
|
|
230
|
+
searchByPath.set(key, [...merged, ...(route.search ?? [])]);
|
|
231
|
+
}
|
|
232
|
+
const unique = [...searchByPath.keys()].sort();
|
|
233
|
+
|
|
180
234
|
const lines = [...HEADER];
|
|
181
235
|
for (const routePath of unique) {
|
|
182
236
|
const params = paramNamesOf(routePath);
|
|
@@ -186,7 +240,21 @@ export function renderRouteTable(paths) {
|
|
|
186
240
|
: `{ ${params.map((name) => `${name}: string`).join("; ")} }`;
|
|
187
241
|
lines.push(` ${JSON.stringify(routePath)}: ${shape};`);
|
|
188
242
|
}
|
|
189
|
-
lines.push(" }"
|
|
243
|
+
lines.push(" }");
|
|
244
|
+
|
|
245
|
+
// Only emitted when some route declares keys: an app that declares none gets exactly
|
|
246
|
+
// the file it had before, and no empty interface for its own linter to complain about.
|
|
247
|
+
const withSearch = unique.filter((routePath) => searchByPath.get(routePath).length > 0);
|
|
248
|
+
if (withSearch.length > 0) {
|
|
249
|
+
lines.push(" interface TerpRouteSearchTable {");
|
|
250
|
+
for (const routePath of withSearch) {
|
|
251
|
+
const keys = [...new Set(searchByPath.get(routePath))].sort();
|
|
252
|
+
const shape = keys.map((name) => `${name}?: string`).join("; ");
|
|
253
|
+
lines.push(` ${JSON.stringify(routePath)}: { ${shape} };`);
|
|
254
|
+
}
|
|
255
|
+
lines.push(" }");
|
|
256
|
+
}
|
|
257
|
+
lines.push("}", "");
|
|
190
258
|
return lines.join("\n");
|
|
191
259
|
}
|
|
192
260
|
|
|
@@ -202,11 +270,11 @@ export function generateRouteTable(modulesDir) {
|
|
|
202
270
|
"src/modules/<name>/module.tsx; pass --modules-dir if this app keeps them elsewhere.",
|
|
203
271
|
);
|
|
204
272
|
}
|
|
205
|
-
const
|
|
273
|
+
const routes = [];
|
|
206
274
|
const problems = [];
|
|
207
275
|
for (const file of files) {
|
|
208
|
-
const result =
|
|
209
|
-
|
|
276
|
+
const result = extractRoutes(fs.readFileSync(file, "utf8"), path.relative(process.cwd(), file).split(path.sep).join("/"));
|
|
277
|
+
routes.push(...result.routes);
|
|
210
278
|
problems.push(...result.problems);
|
|
211
279
|
}
|
|
212
280
|
if (problems.length > 0) {
|
|
@@ -216,7 +284,7 @@ export function generateRouteTable(modulesDir) {
|
|
|
216
284
|
problems.map((problem) => ` - ${problem}`).join("\n"),
|
|
217
285
|
);
|
|
218
286
|
}
|
|
219
|
-
return renderRouteTable(
|
|
287
|
+
return renderRouteTable(routes);
|
|
220
288
|
}
|
|
221
289
|
|
|
222
290
|
/** Parse `--flag value` / `--flag` argv into a plain object. */
|