@terpjs/react-core 0.6.1 → 0.8.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/README.md +12 -2
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/ModuleNav.tsx +1 -1
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +33 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +27 -4
- package/src/markers.test.ts +468 -0
- package/src/raw.d.ts +15 -1
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.test.tsx +39 -0
- package/src/theme.themes.test.ts +124 -0
- package/src/theme.tsx +62 -14
- package/src/toast.tsx +35 -71
- package/src/tokens.guard.test.ts +3 -12
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +13 -25
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/uiText.tsx +9 -0
- package/src/ui/controlStyles.ts +0 -9
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The `data-terp` marker inventory, pinned.
|
|
4
|
+
//
|
|
5
|
+
// A marker names a sanctioned component's rendered root. Two things already read them: the
|
|
6
|
+
// layout contract's runtime slot check (ADR 0079) verifies the identity of the components a
|
|
7
|
+
// body slot contains, and `TERP_STYLES_CSS` hangs every hover/active/disabled/selected
|
|
8
|
+
// declaration off them. So a marker is not decoration — it is the join between a component
|
|
9
|
+
// and both its enforcement and its styling.
|
|
10
|
+
//
|
|
11
|
+
// Nothing held that join in place. A renamed marker silently unstyles a component (the
|
|
12
|
+
// stylesheet rule stops matching, and no test asserts the rule matched anything) and
|
|
13
|
+
// silently widens a layout slot. Renaming is also exactly what a refactor does casually,
|
|
14
|
+
// because the string looks like a debug hook.
|
|
15
|
+
//
|
|
16
|
+
// Two sides are scanned separately and that separation is the whole point of this file:
|
|
17
|
+
// `styles.ts` *consumes* markers as CSS selectors, every component *produces* them as DOM
|
|
18
|
+
// attributes. Scanning them together is worse than not testing at all — the selectors in
|
|
19
|
+
// the sheet re-supply any name a component stopped rendering, so deleting a marker from a
|
|
20
|
+
// component leaves the inventory intact and the suite green while the app loses its styling.
|
|
21
|
+
//
|
|
22
|
+
// The ambient `ImportMeta.glob` type lives in raw.d.ts, shared with the other scanning tests.
|
|
23
|
+
|
|
24
|
+
import manifest from "../package.json";
|
|
25
|
+
|
|
26
|
+
const sources = import.meta.glob("./**/*.{ts,tsx}", {
|
|
27
|
+
query: "?raw",
|
|
28
|
+
import: "default",
|
|
29
|
+
eager: true,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** The single module holding the framework stylesheet: markers appear there as selectors. */
|
|
33
|
+
const STYLESHEET = "./styles.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Every marker a component renders today.
|
|
37
|
+
*
|
|
38
|
+
* Adding a component adds a name here. Renaming one is a breaking change to both the
|
|
39
|
+
* stylesheet and the layout contract, so it belongs in a release note.
|
|
40
|
+
*/
|
|
41
|
+
const MARKERS = [
|
|
42
|
+
"alert",
|
|
43
|
+
"alert-body",
|
|
44
|
+
"alert-icon",
|
|
45
|
+
"alert-title",
|
|
46
|
+
"appshell",
|
|
47
|
+
"appshell-backdrop",
|
|
48
|
+
"appshell-brand",
|
|
49
|
+
"appshell-brand-row",
|
|
50
|
+
"appshell-brand-title",
|
|
51
|
+
"appshell-column",
|
|
52
|
+
"appshell-footer",
|
|
53
|
+
"appshell-header",
|
|
54
|
+
"appshell-header-group",
|
|
55
|
+
"appshell-main",
|
|
56
|
+
"appshell-nav",
|
|
57
|
+
"appshell-nav-label",
|
|
58
|
+
"appshell-nav-list",
|
|
59
|
+
"appshell-sidebar",
|
|
60
|
+
"badge",
|
|
61
|
+
"breadcrumbs",
|
|
62
|
+
"breadcrumbs-current",
|
|
63
|
+
"breadcrumbs-separator",
|
|
64
|
+
"button",
|
|
65
|
+
"button-icon",
|
|
66
|
+
"calendar",
|
|
67
|
+
"calendar-day",
|
|
68
|
+
"calendar-grid",
|
|
69
|
+
"calendar-header",
|
|
70
|
+
"calendar-title",
|
|
71
|
+
"calendar-week",
|
|
72
|
+
"calendar-weekday",
|
|
73
|
+
"card",
|
|
74
|
+
"card-actions",
|
|
75
|
+
"card-description",
|
|
76
|
+
"card-header",
|
|
77
|
+
"card-heading",
|
|
78
|
+
"card-title",
|
|
79
|
+
"checkbox",
|
|
80
|
+
"combobox",
|
|
81
|
+
"combobox-empty",
|
|
82
|
+
"combobox-field",
|
|
83
|
+
"combobox-list",
|
|
84
|
+
"combobox-option",
|
|
85
|
+
"control-label",
|
|
86
|
+
"dataview",
|
|
87
|
+
"dataview-actions-cell",
|
|
88
|
+
"dataview-card",
|
|
89
|
+
"dataview-card-body",
|
|
90
|
+
"dataview-card-expanded",
|
|
91
|
+
"dataview-card-fields",
|
|
92
|
+
"dataview-card-heading",
|
|
93
|
+
"dataview-card-list",
|
|
94
|
+
"dataview-card-main",
|
|
95
|
+
"dataview-card-meta",
|
|
96
|
+
"dataview-card-status",
|
|
97
|
+
"dataview-card-title",
|
|
98
|
+
"dataview-column-option",
|
|
99
|
+
"dataview-column-resizer",
|
|
100
|
+
"dataview-column-settings",
|
|
101
|
+
"dataview-column-settings-title",
|
|
102
|
+
"dataview-column-sort",
|
|
103
|
+
"dataview-error",
|
|
104
|
+
"dataview-expand-cell",
|
|
105
|
+
"dataview-expanded-cell",
|
|
106
|
+
"dataview-inline-action",
|
|
107
|
+
"dataview-pager",
|
|
108
|
+
"dataview-pagination",
|
|
109
|
+
"dataview-row",
|
|
110
|
+
"dataview-row-actions",
|
|
111
|
+
"dataview-row-open",
|
|
112
|
+
"dataview-scroll",
|
|
113
|
+
"dataview-select-cell",
|
|
114
|
+
"dataview-skeleton",
|
|
115
|
+
"dataview-table",
|
|
116
|
+
"dataview-toolbar",
|
|
117
|
+
"dataview-toolbar-actions",
|
|
118
|
+
"dataview-toolbar-count",
|
|
119
|
+
"dataview-toolbar-layout",
|
|
120
|
+
"dataview-toolbar-search",
|
|
121
|
+
"dataview-toolbar-spacer",
|
|
122
|
+
"dataview-toolbar-status",
|
|
123
|
+
"detail-list",
|
|
124
|
+
"detail-list-term",
|
|
125
|
+
"detail-list-value",
|
|
126
|
+
"dialog",
|
|
127
|
+
"dialog-actions",
|
|
128
|
+
"dialog-body",
|
|
129
|
+
"dialog-description",
|
|
130
|
+
"dialog-title",
|
|
131
|
+
"drawer-focus-end",
|
|
132
|
+
"drawer-focus-start",
|
|
133
|
+
"empty-state",
|
|
134
|
+
"empty-state-description",
|
|
135
|
+
"empty-state-icon",
|
|
136
|
+
"empty-state-title",
|
|
137
|
+
"error-state",
|
|
138
|
+
"error-state-description",
|
|
139
|
+
"error-state-icon",
|
|
140
|
+
"error-state-title",
|
|
141
|
+
"field",
|
|
142
|
+
"field-error",
|
|
143
|
+
"field-hint",
|
|
144
|
+
"field-label",
|
|
145
|
+
"field-label-text",
|
|
146
|
+
"hubcard",
|
|
147
|
+
"hubcard-body",
|
|
148
|
+
"hubcard-description",
|
|
149
|
+
"hubcard-heading",
|
|
150
|
+
"hubcard-icon",
|
|
151
|
+
"hubcard-link",
|
|
152
|
+
"hubcard-stat",
|
|
153
|
+
"hubcard-title",
|
|
154
|
+
"hubpage-grid",
|
|
155
|
+
"icon",
|
|
156
|
+
"iconbutton",
|
|
157
|
+
"input",
|
|
158
|
+
"language-switcher",
|
|
159
|
+
"language-switcher-label",
|
|
160
|
+
"loading-state",
|
|
161
|
+
"loading-state-spinner",
|
|
162
|
+
"markdown",
|
|
163
|
+
"menu",
|
|
164
|
+
"menu-item",
|
|
165
|
+
"menu-item-check",
|
|
166
|
+
"menu-item-icon",
|
|
167
|
+
"menu-trigger",
|
|
168
|
+
"module-nav",
|
|
169
|
+
"nav-icon",
|
|
170
|
+
"nav-icon-fallback",
|
|
171
|
+
"page-actions",
|
|
172
|
+
"popover",
|
|
173
|
+
"popover-panel",
|
|
174
|
+
"radio",
|
|
175
|
+
"radio-group",
|
|
176
|
+
"radio-group-legend",
|
|
177
|
+
"radio-group-options",
|
|
178
|
+
"resource-list",
|
|
179
|
+
"spinner-ring",
|
|
180
|
+
"stack",
|
|
181
|
+
"switch",
|
|
182
|
+
"tab",
|
|
183
|
+
"tab-list",
|
|
184
|
+
"tab-panel",
|
|
185
|
+
"tabs",
|
|
186
|
+
"theme-toggle",
|
|
187
|
+
"theme-toggle-label",
|
|
188
|
+
"toast",
|
|
189
|
+
"toast-body",
|
|
190
|
+
"toast-icon",
|
|
191
|
+
"toast-title",
|
|
192
|
+
"toast-viewport",
|
|
193
|
+
"tooltip",
|
|
194
|
+
"tooltip-anchor",
|
|
195
|
+
"user-menu",
|
|
196
|
+
"user-menu-avatar",
|
|
197
|
+
"user-menu-email",
|
|
198
|
+
"user-menu-header",
|
|
199
|
+
"user-menu-identity",
|
|
200
|
+
"user-menu-role",
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Styled surfaces that render no marker, and therefore cannot be reached by an attribute
|
|
205
|
+
* selector or verified by a slot check.
|
|
206
|
+
*
|
|
207
|
+
* A ratchet that shrinks only: this list is the worklist for moving component styling out
|
|
208
|
+
* of inline `style={}` and into the sheet, because a surface with no marker has nothing for
|
|
209
|
+
* a rule to match. Providers, hooks, module manifests and view compositions are deliberately
|
|
210
|
+
* absent — they render no styled root of their own.
|
|
211
|
+
*
|
|
212
|
+
* `Badge` and `Tooltip` have graduated: both own a single styled element, so marking them
|
|
213
|
+
* changed no DOM and no pixels. What is left does *not* reduce to adding an attribute, and
|
|
214
|
+
* the reason splits into two shapes worth knowing before the styling migration is planned:
|
|
215
|
+
*
|
|
216
|
+
* - **Delegates its root.** `theme.tsx` (ThemeToggle) and `locale.tsx` (LanguageSwitcher)
|
|
217
|
+
* returned a bare `Menu` in their `inline` variant — the variant the app shell header
|
|
218
|
+
* actually uses — so their root was `Popover`'s wrapper and indistinguishable from any
|
|
219
|
+
* other popover. Both have graduated, and the answer added no DOM: `Popover` takes the
|
|
220
|
+
* root's marker as a prop named `data-terp`, `Menu` threads it through, and each
|
|
221
|
+
* component names its own root with `data-variant` separating the variants. `UserMenu` was
|
|
222
|
+
* the same shape and has graduated too — it was also the last consumer of `Menu`'s
|
|
223
|
+
* `triggerStyle` and `panelStyle` props, which are gone: a marked root makes the trigger
|
|
224
|
+
* reachable by descending from it, and the PANEL, which is portalled to `document.body`
|
|
225
|
+
* and so reachable from nowhere, carries a `data-owner` attribute naming whose panel it
|
|
226
|
+
* is.
|
|
227
|
+
*
|
|
228
|
+
* The prop is named for the attribute deliberately. The scanner below reads `data-terp`
|
|
229
|
+
* sites in component source, so `<Menu data-terp="theme-toggle">` is seen exactly where
|
|
230
|
+
* it looks; a `rootMarker` prop would have put the only mention of the name somewhere the
|
|
231
|
+
* scanner never looks, and a marker rendered by nobody's `data-terp` site is precisely
|
|
232
|
+
* the blind spot this file exists to close.
|
|
233
|
+
* - **Returned a fragment.** `Markdown` emitted a sequence of block elements with no root at
|
|
234
|
+
* all, and the objection to marking it was that a wrapper is a new block box in every
|
|
235
|
+
* consumer's layout. It has graduated, and that objection turned out to be answerable
|
|
236
|
+
* rather than true: the wrapper is `display: contents`, which generates no box, so the
|
|
237
|
+
* blocks stay in-flow siblings and become real flex or grid items of any parent that
|
|
238
|
+
* spaces its children with `gap`. Zero diff by construction — and a prose-rhythm block
|
|
239
|
+
* wrapper remains a later, deliberate change rather than a side effect of marking.
|
|
240
|
+
*
|
|
241
|
+
* Both are styling decisions with visible consequences, not bookkeeping, so they belong to
|
|
242
|
+
* the migration itself rather than to preparation for it. The archetypes and the DataView
|
|
243
|
+
* internals are still unexamined.
|
|
244
|
+
*
|
|
245
|
+
* `PageActions` has graduated, and it was the easiest of the shapes above: it already rendered
|
|
246
|
+
* a real root of its own, so the marker landed on an element that existed and no DOM moved.
|
|
247
|
+
* Note the one thing it does that a rule cannot — it returns `null` when it has no actions at
|
|
248
|
+
* all, so its presence is conditional rather than styled, and `:empty` is not a substitute.
|
|
249
|
+
*
|
|
250
|
+
* `files.tsx` has left the list without gaining a marker, which is the one exit this ratchet
|
|
251
|
+
* allows that is not a migration: it turned out to have no styled surface. Its single
|
|
252
|
+
* declaration was `display: none` on the file picker's hidden plumbing input — the visible
|
|
253
|
+
* control is a `Button` — and that element is now `hidden`, the attribute HTML provides for
|
|
254
|
+
* exactly this. A marker plus a `display: none` rule would have put a component with no visual
|
|
255
|
+
* design into the sheet and offered an app the chance to un-hide it.
|
|
256
|
+
*
|
|
257
|
+
* Worth knowing about the shape of this list, because it flatters two files: it names files
|
|
258
|
+
* with NO marker at all, so one marker on one element exempts the rest of the file. `toast.tsx`
|
|
259
|
+
* and `ConfirmDialog.tsx` were never on it despite styling five and four unreachable elements
|
|
260
|
+
* respectively, because each rendered one `iconbutton` or one `dialog`. Both have since
|
|
261
|
+
* migrated; the gap in the ratchet has not.
|
|
262
|
+
*
|
|
263
|
+
* `Field` has graduated: it renders a root plus label, label text, hint and error markers,
|
|
264
|
+
* so each part of a form field is addressable from the sheet.
|
|
265
|
+
*/
|
|
266
|
+
const UNMARKED_STYLED_SURFACES = [
|
|
267
|
+
"./DetailPage.tsx",
|
|
268
|
+
"./LoginView.tsx",
|
|
269
|
+
"./OverviewPage.tsx",
|
|
270
|
+
"./ProfileView.tsx",
|
|
271
|
+
];
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* How many module-scope base style objects each file still declares — the migration's own
|
|
275
|
+
* measurable, as a ratchet.
|
|
276
|
+
*
|
|
277
|
+
* This exists because `UNMARKED_STYLED_SURFACES` above flatters a file: it lists modules with
|
|
278
|
+
* NO `data-terp` at all, so a single marker on a single element exempts everything else in the
|
|
279
|
+
* file. `toast.tsx` and `ConfirmDialog.tsx` were never on that list while styling five and four
|
|
280
|
+
* unreachable elements respectively, because each rendered one `iconbutton` or one `dialog`.
|
|
281
|
+
* Both have migrated, and the gap had not — until this.
|
|
282
|
+
*
|
|
283
|
+
* Counted per file rather than as a set of filenames, so a PARTIAL migration shows: moving half
|
|
284
|
+
* of `AppShell`'s twenty-two objects into the sheet has to update the number here. Asserted as
|
|
285
|
+
* exact equality, which makes it a ratchet in both directions — a new base style object fails,
|
|
286
|
+
* and a removed one fails until the ledger is corrected. That is the same bargain `MARKERS`
|
|
287
|
+
* strikes, and it is the point: the number is meant to be read during review.
|
|
288
|
+
*
|
|
289
|
+
* What is deliberately NOT counted: a measured value passed inline at a call site. `Icon` sizes
|
|
290
|
+
* its box from a prop that takes any CSS length, `Stack` passes `align` / `justify` through, and
|
|
291
|
+
* `Popover` positions its panel from a rect it measured — ADR 0094 §3 puts all three on the
|
|
292
|
+
* inline side of the line permanently, so counting them would make this list unable to reach
|
|
293
|
+
* zero and therefore unable to mean anything.
|
|
294
|
+
*/
|
|
295
|
+
const INLINE_BASE_STYLES: Record<string, number> = {
|
|
296
|
+
"./LoginView.tsx": 9,
|
|
297
|
+
"./ModuleNav.tsx": 4,
|
|
298
|
+
"./Page.tsx": 5,
|
|
299
|
+
"./ProfileView.tsx": 3,
|
|
300
|
+
"./ResourceList.tsx": 2,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* `text` with comments removed, so prose naming a marker cannot stand in for rendering one.
|
|
305
|
+
*
|
|
306
|
+
* Line comments are only stripped from a `//` that does not follow a colon, which keeps
|
|
307
|
+
* `https://` inside a string intact. Approximate by design: the result is fed to a marker
|
|
308
|
+
* regex, never compiled.
|
|
309
|
+
*/
|
|
310
|
+
function stripComments(text: string) {
|
|
311
|
+
return text.replace(/\/\*[\s\S]*?\*\//g, "").replace(/(^|[^:])\/\/.*$/gm, "$1");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Marker values authored in `text`, in any of the forms the package actually uses:
|
|
316
|
+
* `data-terp="x"`, `data-terp={"x"}`, `data-terp={open ? "x" : "y"}`, and `"data-terp": "x"`.
|
|
317
|
+
*
|
|
318
|
+
* A regex anchored to `data-terp="…"` alone misses the conditional form — which is how the
|
|
319
|
+
* DataView row markers are written — so renaming one of those would not fail anything.
|
|
320
|
+
*
|
|
321
|
+
* The cost of reading a whole expression is that EVERY string literal inside one counts as a
|
|
322
|
+
* marker. `data-terp={variant === "inline" ? "theme-toggle" : undefined}` reports both
|
|
323
|
+
* `inline` and `theme-toggle`, and the first is not a marker at all. That is caught rather
|
|
324
|
+
* than tolerated — the inventory assertion fails on the phantom name — and the fix is to keep
|
|
325
|
+
* a marker expression to marker literals by hoisting the comparison out. Worth knowing before
|
|
326
|
+
* writing a conditional marker, because the failure names a marker nobody added.
|
|
327
|
+
*/
|
|
328
|
+
function markersIn(text: string) {
|
|
329
|
+
const source = stripComments(text);
|
|
330
|
+
const found = new Set<string>();
|
|
331
|
+
const attribute = /data-terp["']?\s*[=:]\s*/g;
|
|
332
|
+
for (let match = attribute.exec(source); match; match = attribute.exec(source)) {
|
|
333
|
+
const rest = source.slice(match.index + match[0].length);
|
|
334
|
+
// An expression container may hold several literals (a ternary); a bare literal holds
|
|
335
|
+
// one. Read only as far as the value actually extends, so the next attribute's string
|
|
336
|
+
// is never absorbed.
|
|
337
|
+
const scope = rest.startsWith("{") ? expressionAt(rest) : rest.slice(0, firstLiteralEnd(rest));
|
|
338
|
+
for (const literal of scope.matchAll(/["']([a-z0-9-]+)["']/g)) {
|
|
339
|
+
found.add(literal[1]!);
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return found;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** The `{ … }` expression starting at `text[0]`, brace-matched. */
|
|
346
|
+
function expressionAt(text: string) {
|
|
347
|
+
let depth = 0;
|
|
348
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
349
|
+
if (text[index] === "{") depth += 1;
|
|
350
|
+
else if (text[index] === "}") {
|
|
351
|
+
depth -= 1;
|
|
352
|
+
if (depth === 0) return text.slice(0, index + 1);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return text;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** The end of the single quoted literal starting at `text[0]`, or 0 when there is none. */
|
|
359
|
+
function firstLiteralEnd(text: string) {
|
|
360
|
+
const quote = text[0];
|
|
361
|
+
if (quote !== '"' && quote !== "'") return 0;
|
|
362
|
+
const close = text.indexOf(quote, 1);
|
|
363
|
+
return close === -1 ? 0 : close + 1;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const production = Object.entries(sources).filter(([file]) => !file.includes(".test."));
|
|
367
|
+
const components = production.filter(([file]) => file !== STYLESHEET);
|
|
368
|
+
|
|
369
|
+
/** Markers a component renders as a DOM attribute. */
|
|
370
|
+
const rendered = new Set(components.flatMap(([, text]) => [...markersIn(text)]));
|
|
371
|
+
|
|
372
|
+
/** Markers the framework stylesheet targets as `[data-terp="…"]` selectors. */
|
|
373
|
+
const styled = new Set(
|
|
374
|
+
[...(sources[STYLESHEET] ?? "").matchAll(/\[data-terp=["']([a-z0-9-]+)["']\]/g)].map(
|
|
375
|
+
(match) => match[1]!,
|
|
376
|
+
),
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
describe("data-terp markers", () => {
|
|
380
|
+
it("reads both sides of the join it is asserting about", () => {
|
|
381
|
+
expect(components.length).toBeGreaterThan(0);
|
|
382
|
+
expect(sources[STYLESHEET], `${STYLESHEET} is not in the scanned source`).toBeDefined();
|
|
383
|
+
expect(rendered.size).toBeGreaterThan(0);
|
|
384
|
+
expect(styled.size).toBeGreaterThan(0);
|
|
385
|
+
// The two sets must be gathered from different files, or the separation is cosmetic.
|
|
386
|
+
expect(components.some(([file]) => file === STYLESHEET)).toBe(false);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it("renders exactly the pinned inventory", () => {
|
|
390
|
+
// Sorted both sides so the diff on failure names the added or removed marker rather
|
|
391
|
+
// than showing two long reordered lists.
|
|
392
|
+
expect([...rendered].sort()).toEqual([...MARKERS].sort());
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it("pins the inventory in sorted order with no duplicates", () => {
|
|
396
|
+
// The list is read by humans during review; an unsorted or duplicated entry makes an
|
|
397
|
+
// addition look like a rename.
|
|
398
|
+
expect(MARKERS).toEqual([...new Set(MARKERS)].sort());
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
it("styles no marker that no component renders", () => {
|
|
402
|
+
// A selector matching nothing is dead styling that reads as live: the rule is right
|
|
403
|
+
// there in the sheet, so the state it describes looks handled when it is not.
|
|
404
|
+
expect([...styled].filter((marker) => !rendered.has(marker)).sort()).toEqual([]);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it("keeps the unmarked-surface worklist accurate", () => {
|
|
408
|
+
// One direction only: a file that gained a marker must leave the list, or the ratchet
|
|
409
|
+
// stops meaning anything. It deliberately does not assert the converse — every
|
|
410
|
+
// provider, hook and manifest in the package renders no marker legitimately, so
|
|
411
|
+
// requiring the list to name every unmarked file would make it a list of everything.
|
|
412
|
+
// A *new* unmarked primitive is therefore caught at review, not here.
|
|
413
|
+
const stillUnmarked = components
|
|
414
|
+
.filter(([, text]) => markersIn(text).size === 0)
|
|
415
|
+
.map(([file]) => file);
|
|
416
|
+
expect(
|
|
417
|
+
UNMARKED_STYLED_SURFACES.filter((file) => !stillUnmarked.includes(file)),
|
|
418
|
+
"these now render a marker — remove them from the worklist",
|
|
419
|
+
).toEqual([]);
|
|
420
|
+
expect(
|
|
421
|
+
UNMARKED_STYLED_SURFACES,
|
|
422
|
+
"the worklist must stay sorted and duplicate-free",
|
|
423
|
+
).toEqual([...new Set(UNMARKED_STYLED_SURFACES)].sort());
|
|
424
|
+
});
|
|
425
|
+
it("keeps the inline base-style ledger honest, file by file", () => {
|
|
426
|
+
// The migration's measurable, gated. A base style object is a module-scope CSSProperties
|
|
427
|
+
// literal or factory — the shape a component uses to style its own root, and the shape the
|
|
428
|
+
// sheet replaces. Comments are stripped first so prose naming the type cannot count.
|
|
429
|
+
const declared: Record<string, number> = {};
|
|
430
|
+
for (const [file, text] of production) {
|
|
431
|
+
const matches = stripComments(text).match(/CSSProperties\s*(?:=\s*\{|=>\s*\()/g);
|
|
432
|
+
if (matches !== null) {
|
|
433
|
+
declared[file] = matches.length;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
expect(declared).toEqual(INLINE_BASE_STYLES);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("injects the sheet from every module that owns a rule, or is reachable from one that does", () => {
|
|
440
|
+
// Twelve marker-rendering modules never call injectTerpStyles and do not need to: the
|
|
441
|
+
// package publishes ONE entry point and declares no `sideEffects`, so importing anything
|
|
442
|
+
// from it loads every module and two dozen of them inject. That guarantee is a packaging
|
|
443
|
+
// property, and nothing asserted it — a `sideEffects: false` added for bundle size, plus
|
|
444
|
+
// tree-shaking, would remove it silently and the first symptom would be Markdown's blocks
|
|
445
|
+
// collapsing into one grid item. So the property itself is what this pins.
|
|
446
|
+
expect(Object.keys(manifest.exports)).toEqual(["."]);
|
|
447
|
+
expect(manifest.exports["."]).toBe("./src/index.ts");
|
|
448
|
+
expect(
|
|
449
|
+
"sideEffects" in manifest,
|
|
450
|
+
"declaring sideEffects would let a bundler drop the modules that inject the stylesheet",
|
|
451
|
+
).toBe(false);
|
|
452
|
+
// And the sheet has many independent injectors reachable from that entry, or the packaging
|
|
453
|
+
// properties above prove nothing on their own. The entry does NOT re-export ./styles — the
|
|
454
|
+
// injection is a module side effect of the components themselves, which is exactly why the
|
|
455
|
+
// sideEffects assertion is the one that matters.
|
|
456
|
+
const injectors = production.filter(([, text]) =>
|
|
457
|
+
stripComments(text).includes("injectTerpStyles()"),
|
|
458
|
+
);
|
|
459
|
+
expect(injectors.length).toBeGreaterThan(20);
|
|
460
|
+
const index = sources["./index.ts"] ?? "";
|
|
461
|
+
for (const [file] of injectors.slice(0, 3)) {
|
|
462
|
+
const module = file.replace(/^\.\//, "./").replace(/\.tsx?$/, "");
|
|
463
|
+
expect(index, `${file} injects the sheet but is not reachable from the entry point`).toContain(
|
|
464
|
+
`from "${module}"`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
});
|
|
468
|
+
});
|
package/src/raw.d.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Minimal
|
|
2
|
+
* Minimal ambient declarations for the source-scanning tests only — the package keeps
|
|
3
3
|
* `"types": []` so component source never sees ambient Node globals.
|
|
4
|
+
*
|
|
5
|
+
* Both live here rather than inline in the tests that use them: a global augmentation
|
|
6
|
+
* repeated in two files is a TS2717 the moment the copies disagree, and they disagree
|
|
7
|
+
* silently until someone widens one of them.
|
|
4
8
|
*/
|
|
5
9
|
declare module "node:fs" {
|
|
6
10
|
export function readFileSync(path: URL | string, encoding: "utf-8"): string;
|
|
7
11
|
}
|
|
12
|
+
|
|
13
|
+
// Declared at top level, not inside `declare global`: this file is a global script (it has
|
|
14
|
+
// no imports or exports), so the interface merges with the ambient `ImportMeta` directly —
|
|
15
|
+
// `declare global` is only meaningful from inside a module.
|
|
16
|
+
interface ImportMeta {
|
|
17
|
+
glob: (
|
|
18
|
+
pattern: string,
|
|
19
|
+
options: { query: "?raw"; import: "default"; eager: true },
|
|
20
|
+
) => Record<string, string>;
|
|
21
|
+
}
|
package/src/router.tsx
CHANGED
|
@@ -265,18 +265,15 @@ export function buildAppRouter(
|
|
|
265
265
|
logo={options.logo}
|
|
266
266
|
footer={options.footer}
|
|
267
267
|
nav={nav}
|
|
268
|
-
renderBrandLink={({ to, children
|
|
269
|
-
<Link to={to} data-terp="appshell-brand"
|
|
268
|
+
renderBrandLink={({ to, children }) => (
|
|
269
|
+
<Link to={to} data-terp="appshell-brand">
|
|
270
270
|
{children}
|
|
271
271
|
</Link>
|
|
272
272
|
)}
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
activeProps={{ style: { ...context.style, ...context.activeStyle } }}
|
|
278
|
-
activeOptions={{ exact: item.to === "/" }}
|
|
279
|
-
>
|
|
273
|
+
// No style objects and no activeProps: the shell's stylesheet owns the link
|
|
274
|
+
// geometry and keys the active route on aria-current="page", which Link sets.
|
|
275
|
+
renderLink={(item, children) => (
|
|
276
|
+
<Link to={item.to} activeOptions={{ exact: item.to === "/" }}>
|
|
280
277
|
{children}
|
|
281
278
|
</Link>
|
|
282
279
|
)}
|
package/src/ssr.test.tsx
CHANGED
|
@@ -24,9 +24,7 @@ describe("server rendering", () => {
|
|
|
24
24
|
<AppShell
|
|
25
25
|
title="Terp"
|
|
26
26
|
nav={[{ label: "Home", to: "/", icon: "home" }]}
|
|
27
|
-
renderLink={(item, children
|
|
28
|
-
<a href={item.to} style={context.style}>{children}</a>
|
|
29
|
-
)}
|
|
27
|
+
renderLink={(item, children) => <a href={item.to}>{children}</a>}
|
|
30
28
|
>
|
|
31
29
|
<p>content</p>
|
|
32
30
|
</AppShell>,
|