@charcuterie/ui 0.1.1 → 1.0.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 +9 -0
- package/dist/Accordion/Accordion.d.ts +82 -0
- package/dist/Accordion/Accordion.d.ts.map +1 -0
- package/dist/Accordion/Accordion.js +108 -0
- package/dist/Accordion/Accordion.js.map +1 -0
- package/dist/Accordion/AccordionSection.d.ts +28 -0
- package/dist/Accordion/AccordionSection.d.ts.map +1 -0
- package/dist/Accordion/AccordionSection.js +53 -0
- package/dist/Accordion/AccordionSection.js.map +1 -0
- package/dist/Field/Field.d.ts +104 -0
- package/dist/Field/Field.d.ts.map +1 -0
- package/dist/Field/Field.js +115 -0
- package/dist/Field/Field.js.map +1 -0
- package/dist/Field/FieldGroup.d.ts +58 -0
- package/dist/Field/FieldGroup.d.ts.map +1 -0
- package/dist/Field/FieldGroup.js +65 -0
- package/dist/Field/FieldGroup.js.map +1 -0
- package/dist/FileDropZone/FileDropZone.d.ts +63 -0
- package/dist/FileDropZone/FileDropZone.d.ts.map +1 -0
- package/dist/FileDropZone/FileDropZone.js +122 -0
- package/dist/FileDropZone/FileDropZone.js.map +1 -0
- package/dist/LogViewer/LogViewer.d.ts +90 -0
- package/dist/LogViewer/LogViewer.d.ts.map +1 -0
- package/dist/LogViewer/LogViewer.js +165 -0
- package/dist/LogViewer/LogViewer.js.map +1 -0
- package/dist/Menu/Menu.d.ts +81 -0
- package/dist/Menu/Menu.d.ts.map +1 -0
- package/dist/Menu/Menu.js +199 -0
- package/dist/Menu/Menu.js.map +1 -0
- package/dist/Menu/MenuAction.d.ts +24 -0
- package/dist/Menu/MenuAction.d.ts.map +1 -0
- package/dist/Menu/MenuAction.js +37 -0
- package/dist/Menu/MenuAction.js.map +1 -0
- package/dist/Select/Select.d.ts +87 -0
- package/dist/Select/Select.d.ts.map +1 -0
- package/dist/Select/Select.js +51 -0
- package/dist/Select/Select.js.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts +61 -0
- package/dist/SortableTableHeader/SortableTableHeader.d.ts.map +1 -0
- package/dist/SortableTableHeader/SortableTableHeader.js +56 -0
- package/dist/SortableTableHeader/SortableTableHeader.js.map +1 -0
- package/dist/Toast/Toast.d.ts +54 -0
- package/dist/Toast/Toast.d.ts.map +1 -0
- package/dist/Toast/Toast.js +121 -0
- package/dist/Toast/Toast.js.map +1 -0
- package/dist/Toast/ToastRegion.d.ts +57 -0
- package/dist/Toast/ToastRegion.d.ts.map +1 -0
- package/dist/Toast/ToastRegion.js +41 -0
- package/dist/Toast/ToastRegion.js.map +1 -0
- package/dist/Toast/toastLifecycle.d.ts +34 -0
- package/dist/Toast/toastLifecycle.d.ts.map +1 -0
- package/dist/Toast/toastLifecycle.js +25 -0
- package/dist/Toast/toastLifecycle.js.map +1 -0
- package/dist/Tooltip/Tooltip.d.ts +82 -0
- package/dist/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/Tooltip/Tooltip.js +124 -0
- package/dist/Tooltip/Tooltip.js.map +1 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -1
- package/dist/mountStory.testHelpers.d.ts +0 -11
- package/dist/mountStory.testHelpers.d.ts.map +1 -1
- package/dist/mountStory.testHelpers.js +27 -0
- package/dist/mountStory.testHelpers.js.map +1 -1
- package/dist/slotProps.d.ts +110 -0
- package/dist/slotProps.d.ts.map +1 -0
- package/dist/slotProps.js +39 -0
- package/dist/slotProps.js.map +1 -0
- package/package.json +3 -3
- package/src/Accordion/Accordion.mdx +83 -0
- package/src/Accordion/Accordion.stories.tsx +146 -0
- package/src/Accordion/Accordion.test.tsx +163 -0
- package/src/Accordion/Accordion.tsx +182 -0
- package/src/Accordion/AccordionSection.tsx +163 -0
- package/src/Field/Field.mdx +144 -0
- package/src/Field/Field.stories.tsx +280 -0
- package/src/Field/Field.test.tsx +344 -0
- package/src/Field/Field.tsx +212 -0
- package/src/Field/FieldGroup.tsx +138 -0
- package/src/FileDropZone/FileDropZone.mdx +78 -0
- package/src/FileDropZone/FileDropZone.stories.tsx +136 -0
- package/src/FileDropZone/FileDropZone.test.tsx +230 -0
- package/src/FileDropZone/FileDropZone.tsx +234 -0
- package/src/LogViewer/LogViewer.mdx +123 -0
- package/src/LogViewer/LogViewer.stories.tsx +169 -0
- package/src/LogViewer/LogViewer.test.tsx +236 -0
- package/src/LogViewer/LogViewer.tsx +261 -0
- package/src/Menu/Menu.mdx +103 -0
- package/src/Menu/Menu.stories.tsx +161 -0
- package/src/Menu/Menu.test.tsx +202 -0
- package/src/Menu/Menu.tsx +300 -0
- package/src/Menu/MenuAction.tsx +95 -0
- package/src/Select/Select.mdx +74 -0
- package/src/Select/Select.stories.tsx +126 -0
- package/src/Select/Select.test.tsx +129 -0
- package/src/Select/Select.tsx +200 -0
- package/src/SortableTableHeader/SortableTableHeader.mdx +67 -0
- package/src/SortableTableHeader/SortableTableHeader.stories.tsx +207 -0
- package/src/SortableTableHeader/SortableTableHeader.test.tsx +180 -0
- package/src/SortableTableHeader/SortableTableHeader.tsx +131 -0
- package/src/Toast/Toast.mdx +84 -0
- package/src/Toast/Toast.stories.tsx +192 -0
- package/src/Toast/Toast.test.tsx +217 -0
- package/src/Toast/Toast.tsx +224 -0
- package/src/Toast/ToastRegion.tsx +98 -0
- package/src/Toast/toastLifecycle.ts +45 -0
- package/src/Tooltip/Tooltip.mdx +69 -0
- package/src/Tooltip/Tooltip.stories.tsx +104 -0
- package/src/Tooltip/Tooltip.test.tsx +138 -0
- package/src/Tooltip/Tooltip.tsx +193 -0
- package/src/index.ts +47 -0
- package/src/mountStory.testHelpers.ts +30 -0
- package/src/slotProps.ts +143 -0
- package/src/sourceRules.test.ts +221 -30
package/src/slotProps.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { InputHTMLAttributes } from "react"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The props a **cloning ancestor** injects into the control below it.
|
|
5
|
+
*
|
|
6
|
+
* `Field` and `Tooltip` both take one child and `cloneElement` onto
|
|
7
|
+
* it — "cloned, not wrapped", which is what lets a `Field` go around
|
|
8
|
+
* an `<input>`, a `Select`, or an app's own control without changing
|
|
9
|
+
* that control's layout. The contract has a hole in it the moment two
|
|
10
|
+
* of them are nested:
|
|
11
|
+
*
|
|
12
|
+
* ```tsx
|
|
13
|
+
* <Field label="Pattern" isRequired>
|
|
14
|
+
* <Tooltip label="A JavaScript regular expression">
|
|
15
|
+
* <input />
|
|
16
|
+
* </Tooltip>
|
|
17
|
+
* </Field>
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `Field` clones onto the **`Tooltip` element**, so `id`,
|
|
21
|
+
* `aria-describedby`, `aria-invalid` and `required` are handed to a
|
|
22
|
+
* component that declares none of them — and `cloneElement` does not
|
|
23
|
+
* care. React drops them without a warning, TypeScript never sees
|
|
24
|
+
* them (`Children.only` returns a `ReactElement` with `any` props),
|
|
25
|
+
* every test still passes, and the render is pixel-identical. The
|
|
26
|
+
* `<label>` then points at an `id` that is nowhere in the document.
|
|
27
|
+
*
|
|
28
|
+
* **Silent prop-dropping is the worst version of this**, so the rule
|
|
29
|
+
* is: *a slot component is a pass-through*. Anything in `SlotProps`
|
|
30
|
+
* that arrives from above is forwarded to the child this component
|
|
31
|
+
* clones onto, so a chain of slots behaves like the one control at
|
|
32
|
+
* the bottom of it.
|
|
33
|
+
*
|
|
34
|
+
* ### The list is closed, the forwarding is not
|
|
35
|
+
*
|
|
36
|
+
* Worth being exact, because the two are easy to conflate. This type
|
|
37
|
+
* is a closed list of five keys rather than an index signature: it is
|
|
38
|
+
* exactly the wiring `Field` writes plus the wiring `Tooltip` writes,
|
|
39
|
+
* it is greppable, and it is the set whose **merge semantics are
|
|
40
|
+
* defined** — which for four of them is last-write-wins and for
|
|
41
|
+
* `aria-describedby` is a join.
|
|
42
|
+
*
|
|
43
|
+
* The *runtime* forwards everything. Both components collect their
|
|
44
|
+
* received props with a rest spread and `mergeSlotProps` starts from
|
|
45
|
+
* `...receivedProps`, so an ancestor's `onPointerEnter`, its
|
|
46
|
+
* `onFocus`, its `ref` — none of which are named here — reach the
|
|
47
|
+
* control at the bottom too. That is not an oversight, it is load
|
|
48
|
+
* bearing: a `Tooltip` around a `Field` hands down `useHover`,
|
|
49
|
+
* `useFocus`, `useDismiss` and `refs.setReference`, and a `Field`
|
|
50
|
+
* that forwarded only the five keys below would leave the tip with no
|
|
51
|
+
* trigger and no anchor. A closed *runtime* filter would be
|
|
52
|
+
* mux-magic's `FieldTooltip` defect rebuilt inside the fix for it.
|
|
53
|
+
*
|
|
54
|
+
* The five keys, and who writes them:
|
|
55
|
+
*
|
|
56
|
+
* | Key | Written by |
|
|
57
|
+
* | --- | --- |
|
|
58
|
+
* | `aria-describedby` | `Field` (its description and its error) **and** `Tooltip` (its tip) — the one key that is a list, and the reason `mergeSlotProps` exists |
|
|
59
|
+
* | `aria-invalid` | `Field`, derived from `error` being present |
|
|
60
|
+
* | `aria-required` | `Field`, from `isRequired` — the announced constraint, for a composite control the browser does not validate |
|
|
61
|
+
* | `id` | `Field`, so its `<label htmlFor>` has something to point at. A caller may also pass one directly, which is how a consumer pins a stable id for a deep link or an autofill hint |
|
|
62
|
+
* | `required` | `Field`, from `isRequired` — the constraint the browser validates |
|
|
63
|
+
*
|
|
64
|
+
* ### Why these are `Pick`ed and not written out
|
|
65
|
+
*
|
|
66
|
+
* Three of the five are booleans whose names cannot start with `is`
|
|
67
|
+
* or `has`, because they are not our names — they are the DOM's, and
|
|
68
|
+
* they are spread onto a DOM node verbatim. The house rule has
|
|
69
|
+
* [no carve-out](../../../docs/decisions/2026-07-29-is-has-rule-has-no-external-api-carve-out.md)
|
|
70
|
+
* for that: a *type property* is a shape we own, an object literal
|
|
71
|
+
* key is someone else's contract, and no `eslint-disable` is
|
|
72
|
+
* available because unused disable directives are themselves an
|
|
73
|
+
* error.
|
|
74
|
+
*
|
|
75
|
+
* So this type does not declare a shape. It **selects** one out of
|
|
76
|
+
* React's own `InputHTMLAttributes`, which is the honest description
|
|
77
|
+
* of what it is: five keys of the props React already types for an
|
|
78
|
+
* `<input>`, forwarded unchanged. The same reasoning as the
|
|
79
|
+
* `Record<string, boolean>` in `createReactAdapter` — we are not
|
|
80
|
+
* describing React's shape, we are naming the keys of it we touch —
|
|
81
|
+
* and it has the side benefit that `aria-invalid` gets React's real
|
|
82
|
+
* type (`"grammar"` and `"spelling"` are legal values) rather than
|
|
83
|
+
* the `boolean` a hand-written version would have narrowed it to.
|
|
84
|
+
*/
|
|
85
|
+
export type SlotProps = Pick<
|
|
86
|
+
InputHTMLAttributes<HTMLElement>,
|
|
87
|
+
| "aria-describedby"
|
|
88
|
+
| "aria-invalid"
|
|
89
|
+
| "aria-required"
|
|
90
|
+
| "id"
|
|
91
|
+
| "required"
|
|
92
|
+
>
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Merge what arrived from above with what this component writes
|
|
96
|
+
* itself.
|
|
97
|
+
*
|
|
98
|
+
* Everything here is a single value and last-write-wins is correct
|
|
99
|
+
* for it — **except `aria-describedby`, which is a list**. Two slot
|
|
100
|
+
* components each writing their own onto one control is the entire
|
|
101
|
+
* nesting problem in one attribute: a `Field` naming its description
|
|
102
|
+
* and its error, and a `Tooltip` naming its tip. A plain spread keeps
|
|
103
|
+
* one of them and loses the other, which is the same silent drop by a
|
|
104
|
+
* shorter route.
|
|
105
|
+
*
|
|
106
|
+
* **Outer first.** A screen reader reads the list in order, and the
|
|
107
|
+
* field's own description and error are the control's primary
|
|
108
|
+
* explanation; a tip is supplementary by construction. The ordering
|
|
109
|
+
* inside a single `Field` — description, then error — is `Field`'s
|
|
110
|
+
* own concern and is unaffected.
|
|
111
|
+
*
|
|
112
|
+
* `undefined` in `ownProps` is *absence*, not an instruction to
|
|
113
|
+
* clear. Both callers spell their optional wiring as
|
|
114
|
+
* `isRequired || undefined`, so a plain spread would let an inner
|
|
115
|
+
* `Field` with no error erase an outer one's `aria-invalid` — the
|
|
116
|
+
* same silent drop, one level down.
|
|
117
|
+
*/
|
|
118
|
+
export const mergeSlotProps = <
|
|
119
|
+
OwnProps extends Record<string, unknown>,
|
|
120
|
+
>(
|
|
121
|
+
receivedProps: SlotProps,
|
|
122
|
+
ownProps: OwnProps,
|
|
123
|
+
): OwnProps & SlotProps => {
|
|
124
|
+
const definedOwnProps = Object.fromEntries(
|
|
125
|
+
Object.entries(ownProps).filter(
|
|
126
|
+
([, value]) => value !== undefined,
|
|
127
|
+
),
|
|
128
|
+
) as OwnProps
|
|
129
|
+
|
|
130
|
+
const describedBy = [
|
|
131
|
+
receivedProps["aria-describedby"],
|
|
132
|
+
ownProps["aria-describedby"],
|
|
133
|
+
]
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join(" ")
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
...receivedProps,
|
|
139
|
+
...definedOwnProps,
|
|
140
|
+
"aria-describedby":
|
|
141
|
+
describedBy === "" ? undefined : describedBy,
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/sourceRules.test.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
* axe run can see.
|
|
2
|
+
* The rules about this package's source — and about the shape of the
|
|
3
|
+
* workspace around it — that no compiler and no axe run can see.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* They come straight from the plan's verification list, and each one
|
|
6
|
+
* is a thing the fleet does today that the library exists to stop.
|
|
7
|
+
*
|
|
8
|
+
* The last two reach past `@charcuterie/ui` into its siblings on
|
|
9
|
+
* purpose. `ui` sits at the top of the dependency graph, so it is the
|
|
10
|
+
* one package whose tests can see all three at once — which is why
|
|
11
|
+
* the plan's cross-package assertions live here rather than in a
|
|
12
|
+
* `packages/conformance` of their own
|
|
13
|
+
* ([decision](../../../docs/decisions/2026-07-31-conformance-is-not-a-package.md)).
|
|
8
14
|
*/
|
|
9
15
|
|
|
10
16
|
import { readdir, readFile } from "node:fs/promises"
|
|
11
|
-
import { join, relative, resolve } from "node:path"
|
|
17
|
+
import { dirname, join, relative, resolve } from "node:path"
|
|
12
18
|
|
|
13
19
|
import { expect, test } from "vitest"
|
|
14
20
|
|
|
@@ -16,6 +22,24 @@ const sourceDirectory = resolve(import.meta.dirname)
|
|
|
16
22
|
|
|
17
23
|
const packageDirectory = resolve(sourceDirectory, "..")
|
|
18
24
|
|
|
25
|
+
const packagesDirectory = resolve(packageDirectory, "..")
|
|
26
|
+
|
|
27
|
+
type PackageManifest = {
|
|
28
|
+
dependencies?: Record<string, string>
|
|
29
|
+
exports?: Record<string, string | { source?: string }>
|
|
30
|
+
peerDependencies?: Record<string, string>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const readManifest = async (
|
|
34
|
+
name: string,
|
|
35
|
+
): Promise<PackageManifest> =>
|
|
36
|
+
JSON.parse(
|
|
37
|
+
await readFile(
|
|
38
|
+
join(packagesDirectory, name, "package.json"),
|
|
39
|
+
"utf8",
|
|
40
|
+
),
|
|
41
|
+
) as PackageManifest
|
|
42
|
+
|
|
19
43
|
const getSourceFiles = async (
|
|
20
44
|
directory: string,
|
|
21
45
|
): Promise<{ contents: string; file: string }[]> => {
|
|
@@ -250,12 +274,23 @@ test("the barrel is the only place components are re-exported", async () => {
|
|
|
250
274
|
// than app-specific: `Alert` (rip-deck spells it four times, twice
|
|
251
275
|
// with a byte-identical `TONE_CLASS`) and `SegmentedControl`.
|
|
252
276
|
//
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
|
|
277
|
+
// M6 adds the nine P1 components: `Accordion`, `Field`,
|
|
278
|
+
// `FileDropZone`, `LogViewer`, `Menu`, `Select`,
|
|
279
|
+
// `SortableTableHeader`, `Toast`, and `Tooltip`.
|
|
280
|
+
//
|
|
281
|
+
// `TabTrigger`, `SegmentedOption`, `AccordionSection`, and
|
|
282
|
+
// `MenuAction` are deliberately not in this count and not
|
|
283
|
+
// exported. Each is its own component's member — its own file only
|
|
284
|
+
// because both registrations are effects and an effect cannot run
|
|
285
|
+
// in a loop — and the `<Name>/<Name>.tsx` pattern this regex
|
|
286
|
+
// matches is what keeps them out.
|
|
287
|
+
//
|
|
288
|
+
// `ToastRegion` is the one exception worth naming: it *is*
|
|
289
|
+
// exported, and it lives in `Toast/` rather than owning a
|
|
290
|
+
// directory because a region with no toasts in it is not a thing
|
|
291
|
+
// anybody renders. It is therefore outside this count by the same
|
|
292
|
+
// rule, and the barrel assertion below does not reach it.
|
|
293
|
+
expect(componentNames.length).toBe(25)
|
|
259
294
|
|
|
260
295
|
for (const name of componentNames) {
|
|
261
296
|
expect(barrel).toContain(`export { ${name} }`)
|
|
@@ -267,37 +302,193 @@ test("dependency direction is tokens ← logic ← ui", async () => {
|
|
|
267
302
|
// depend on both; `logic` may depend on `tokens`; `tokens` depends
|
|
268
303
|
// on nothing, which is what lets `castkit/packages/views` read a
|
|
269
304
|
// colour without a React tree.
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
join(packageDirectory, "..", name, "package.json"),
|
|
274
|
-
"utf8",
|
|
275
|
-
),
|
|
276
|
-
) as {
|
|
277
|
-
dependencies?: Record<string, string>
|
|
278
|
-
peerDependencies?: Record<string, string>
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
const getCharcuterieDependencies = (manifest: {
|
|
282
|
-
dependencies?: Record<string, string>
|
|
283
|
-
peerDependencies?: Record<string, string>
|
|
284
|
-
}) =>
|
|
305
|
+
const getCharcuterieDependencies = (
|
|
306
|
+
manifest: PackageManifest,
|
|
307
|
+
) =>
|
|
285
308
|
Object.keys({
|
|
286
309
|
...manifest.dependencies,
|
|
287
310
|
...manifest.peerDependencies,
|
|
288
311
|
}).filter((name) => name.startsWith("@charcuterie/"))
|
|
289
312
|
|
|
290
313
|
expect(
|
|
291
|
-
getCharcuterieDependencies(
|
|
314
|
+
getCharcuterieDependencies(
|
|
315
|
+
await readManifest("tokens"),
|
|
316
|
+
),
|
|
292
317
|
).toEqual([])
|
|
293
318
|
|
|
294
319
|
expect(
|
|
295
|
-
getCharcuterieDependencies(await
|
|
320
|
+
getCharcuterieDependencies(await readManifest("logic")),
|
|
296
321
|
).toEqual([])
|
|
297
322
|
|
|
298
323
|
expect(
|
|
299
324
|
getCharcuterieDependencies(
|
|
300
|
-
await
|
|
325
|
+
await readManifest("ui"),
|
|
301
326
|
).sort(),
|
|
302
327
|
).toEqual(["@charcuterie/logic", "@charcuterie/tokens"])
|
|
303
328
|
})
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* What every published entry point is allowed to reach at runtime.
|
|
332
|
+
*
|
|
333
|
+
* This is what survives of the plan's `@charcuterie/conformance`
|
|
334
|
+
* package — "builds React19+TWv4 / Preact / Satori profiles" — once
|
|
335
|
+
* the three profiles are checked against what the library actually
|
|
336
|
+
* became. Two of them are already built and gated by name (`docs` is
|
|
337
|
+
* the React 19 + Tailwind v4 build; `logic`'s five-adapter suite is
|
|
338
|
+
* the Preact one), and the third has no components to render because
|
|
339
|
+
* the ePaper-safe subset was never built. What no gate covered is the
|
|
340
|
+
* claim each of those profiles actually rests on: **which runtime a
|
|
341
|
+
* consumer is forced to install by importing a given entry point.**
|
|
342
|
+
* That is the assertion below.
|
|
343
|
+
*
|
|
344
|
+
* Each of these numbers is load-bearing somewhere:
|
|
345
|
+
*
|
|
346
|
+
* - `tokens` reaches **nothing**. `castkit/packages/views` renders
|
|
347
|
+
* through Satori and must read a colour without a React tree.
|
|
348
|
+
* - `logic/core` reaches **nothing**, and `logic/preact` never
|
|
349
|
+
* reaches `react` — `preact/compat` is most of `slatecast`'s 60 KB
|
|
350
|
+
* gz budget, and an accidental `react` import there is a bundle
|
|
351
|
+
* blow-up that typechecks.
|
|
352
|
+
* - `ui/testing` reaches **nothing**, which is the promise
|
|
353
|
+
* `expectAgentDrivable.ts` makes in prose: it ships for consumers
|
|
354
|
+
* to hold *their* components to, and a published package must not
|
|
355
|
+
* drag a test framework into an app's dependency graph.
|
|
356
|
+
*
|
|
357
|
+
* A type-only import counts. It is erased from the bundle but not
|
|
358
|
+
* from the `.d.ts`, so a consumer still needs the package installed
|
|
359
|
+
* for their own typecheck to pass.
|
|
360
|
+
*/
|
|
361
|
+
const ENTRY_POINT_RUNTIMES: Record<
|
|
362
|
+
string,
|
|
363
|
+
Record<string, string[]>
|
|
364
|
+
> = {
|
|
365
|
+
logic: {
|
|
366
|
+
".": ["react"],
|
|
367
|
+
"./core": [],
|
|
368
|
+
"./jotai": ["jotai"],
|
|
369
|
+
"./preact": ["preact", "preact/hooks"],
|
|
370
|
+
"./signals": ["@preact/signals-core"],
|
|
371
|
+
},
|
|
372
|
+
tokens: {
|
|
373
|
+
".": [],
|
|
374
|
+
"./epaper": [],
|
|
375
|
+
},
|
|
376
|
+
ui: {
|
|
377
|
+
".": [
|
|
378
|
+
"@charcuterie/logic",
|
|
379
|
+
"@charcuterie/tokens",
|
|
380
|
+
"@floating-ui/react",
|
|
381
|
+
"react",
|
|
382
|
+
],
|
|
383
|
+
"./testing": [],
|
|
384
|
+
"./tokens": ["@charcuterie/tokens"],
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Every bare specifier reachable from `entryFile`, following relative
|
|
390
|
+
* imports and stopping at package boundaries.
|
|
391
|
+
*
|
|
392
|
+
* Every relative import in this workspace carries its extension, so
|
|
393
|
+
* "resolution" is a `join` — no `.js`/`index` guessing, and a typo
|
|
394
|
+
* fails loudly as a missing file rather than silently pruning the
|
|
395
|
+
* graph.
|
|
396
|
+
*/
|
|
397
|
+
const collectRuntimeImports = async (entryFile: string) => {
|
|
398
|
+
const visited = new Set<string>()
|
|
399
|
+
|
|
400
|
+
const bareSpecifiers = new Set<string>()
|
|
401
|
+
|
|
402
|
+
const visit = async (file: string) => {
|
|
403
|
+
if (visited.has(file)) {
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
visited.add(file)
|
|
408
|
+
|
|
409
|
+
// Template literals go too. `createStatus`'s error message
|
|
410
|
+
// spells `from "${current}"`, which is prose about a state
|
|
411
|
+
// machine and not an import of a package called `${current}`.
|
|
412
|
+
const contents = stripComments(
|
|
413
|
+
await readFile(file, "utf8"),
|
|
414
|
+
).replace(/`(?:[^`\\]|\\.)*`/g, "")
|
|
415
|
+
|
|
416
|
+
const specifiers = [
|
|
417
|
+
...contents.matchAll(/\bfrom\s+"([^"]+)"/g),
|
|
418
|
+
...contents.matchAll(/\bimport\s+"([^"]+)"/g),
|
|
419
|
+
].map(([, specifier]) => specifier as string)
|
|
420
|
+
|
|
421
|
+
for (const specifier of specifiers) {
|
|
422
|
+
if (!specifier.startsWith(".")) {
|
|
423
|
+
bareSpecifiers.add(specifier)
|
|
424
|
+
|
|
425
|
+
continue
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (/\.css$/.test(specifier)) {
|
|
429
|
+
continue
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
await visit(resolve(dirname(file), specifier))
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
await visit(entryFile)
|
|
437
|
+
|
|
438
|
+
return [...bareSpecifiers].sort()
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
test.each(Object.keys(ENTRY_POINT_RUNTIMES))(
|
|
442
|
+
"every @charcuterie/%s entry point reaches only its own runtime",
|
|
443
|
+
async (packageName) => {
|
|
444
|
+
const manifest = await readManifest(packageName)
|
|
445
|
+
|
|
446
|
+
const entryPoints = Object.entries(
|
|
447
|
+
manifest.exports ?? {},
|
|
448
|
+
).flatMap(([name, target]) =>
|
|
449
|
+
typeof target === "object" && target.source
|
|
450
|
+
? [[name, target.source] as const]
|
|
451
|
+
: [],
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
// The table is exhaustive, so a new entry point cannot land
|
|
455
|
+
// without somebody deciding what it is allowed to pull in.
|
|
456
|
+
expect(
|
|
457
|
+
entryPoints.map(([name]) => name).sort(),
|
|
458
|
+
).toEqual(
|
|
459
|
+
Object.keys(
|
|
460
|
+
ENTRY_POINT_RUNTIMES[packageName] ?? {},
|
|
461
|
+
).sort(),
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
const declared = Object.keys({
|
|
465
|
+
...manifest.dependencies,
|
|
466
|
+
...manifest.peerDependencies,
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
for (const [name, source] of entryPoints) {
|
|
470
|
+
const reached = await collectRuntimeImports(
|
|
471
|
+
join(packagesDirectory, packageName, source),
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
expect({
|
|
475
|
+
entryPoint: `${packageName}${name.slice(1)}`,
|
|
476
|
+
reached,
|
|
477
|
+
}).toEqual({
|
|
478
|
+
entryPoint: `${packageName}${name.slice(1)}`,
|
|
479
|
+
reached: ENTRY_POINT_RUNTIMES[packageName]?.[name],
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
// And nothing reached is a phantom dependency —
|
|
483
|
+
// `preact/hooks` is declared as `preact`, which is the unit an
|
|
484
|
+
// install actually has.
|
|
485
|
+
for (const specifier of reached) {
|
|
486
|
+
const packageOfSpecifier = specifier.startsWith("@")
|
|
487
|
+
? specifier.split("/").slice(0, 2).join("/")
|
|
488
|
+
: specifier.split("/")[0]
|
|
489
|
+
|
|
490
|
+
expect(declared).toContain(packageOfSpecifier)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
},
|
|
494
|
+
)
|