cursedbelt 4.7.0 → 5.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 +36 -0
- package/dist/react/chat/index.d.ts +1 -0
- package/dist/react/chat/index.d.ts.map +1 -1
- package/dist/react/chat/index.js +1 -0
- package/dist/react/chat/index.js.map +1 -1
- package/dist/react/chat/usePresence.d.ts +75 -0
- package/dist/react/chat/usePresence.d.ts.map +1 -0
- package/dist/react/chat/usePresence.js +109 -0
- package/dist/react/chat/usePresence.js.map +1 -0
- package/dist/react/components/SecretInput.d.ts +7 -2
- package/dist/react/components/SecretInput.d.ts.map +1 -1
- package/dist/react/components/SecretInput.js +5 -2
- package/dist/react/components/SecretInput.js.map +1 -1
- package/dist/react/components/TooltipImpl.d.ts.map +1 -1
- package/dist/react/components/TooltipImpl.js +52 -3
- package/dist/react/components/TooltipImpl.js.map +1 -1
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockDialog.js +2 -1
- package/dist/react/master-lock/MasterLockDialog.js.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.d.ts +12 -18
- package/dist/react/master-lock/MasterLockProvider.d.ts.map +1 -1
- package/dist/react/master-lock/MasterLockProvider.js +14 -20
- package/dist/react/master-lock/MasterLockProvider.js.map +1 -1
- package/dist/react/media/MediaUploadControl.d.ts +3 -7
- package/dist/react/media/MediaUploadControl.d.ts.map +1 -1
- package/dist/react/media/MediaUploadControl.js +4 -13
- package/dist/react/media/MediaUploadControl.js.map +1 -1
- package/dist/react/media/fileUploadClient.d.ts +0 -8
- package/dist/react/media/fileUploadClient.d.ts.map +1 -1
- package/dist/react/media/fileUploadClient.js +0 -8
- package/dist/react/media/fileUploadClient.js.map +1 -1
- package/dist/react/media/hlsSource.d.ts.map +1 -1
- package/dist/react/media/hlsSource.js +4 -2
- package/dist/react/media/hlsSource.js.map +1 -1
- package/dist/react/media/index.d.ts +0 -4
- package/dist/react/media/index.d.ts.map +1 -1
- package/dist/react/media/index.js +0 -4
- package/dist/react/media/index.js.map +1 -1
- package/dist/react/sessionAuth.d.ts +1 -2
- package/dist/react/sessionAuth.d.ts.map +1 -1
- package/dist/react/sessionAuth.js +2 -3
- package/dist/react/sessionAuth.js.map +1 -1
- package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
- package/dist/styles-areas/activity-bar.css +1 -1
- package/dist/styles-areas/auth.css +1 -1
- package/dist/styles-areas/charts.css +1 -1
- package/dist/styles-areas/chat.css +1 -1
- package/dist/styles-areas/core.css +1 -1
- package/dist/styles-areas/data-table.css +1 -1
- package/dist/styles-areas/disk-usage.css +1 -1
- package/dist/styles-areas/fields.css +1 -1
- package/dist/styles-areas/master-detail.css +1 -1
- package/dist/styles-areas/master-lock.css +108 -2
- package/dist/styles-areas/media-gallery.css +1 -1
- package/dist/styles-areas/media.css +1 -17
- package/dist/styles-areas/nav.css +1 -1
- package/dist/styles-areas/notifications.css +1 -1
- package/dist/styles-areas/spreadsheet.css +1 -1
- package/dist/styles-areas/stats.css +1 -1
- package/dist/styles-areas/wizard.css +1 -1
- package/dist/styles-areas/workbook-viewer.css +1 -1
- package/dist/styles-utilities.css +0 -16
- package/dist/testing/_noBun.d.ts +2 -0
- package/dist/testing/_noBun.d.ts.map +1 -0
- package/dist/testing/_noBun.js +10 -0
- package/dist/testing/_noBun.js.map +1 -0
- package/package.json +42 -19
- package/scripts/checkAreaStyles.spec.ts +71 -0
- package/scripts/checkAreaStyles.ts +150 -0
- package/scripts/guardrailsEnforce.spec.ts +43 -0
- package/scripts/guardrailsEnforce.ts +46 -1
- package/scripts/styleAreas.ts +1 -2
- package/src/barrelsReachNoOptionalPeer.spec.ts +6 -3
- package/src/bundlerOnlyEntries.spec.ts +101 -0
- package/src/declaredImports.spec.ts +4 -0
- package/src/optionalPeersTable.spec.ts +127 -0
- package/src/react/chat/index.ts +8 -0
- package/src/react/chat/usePresence.spec.tsx +138 -0
- package/src/react/chat/usePresence.ts +185 -0
- package/src/react/components/SecretInput.tsx +12 -1
- package/src/react/components/TooltipImpl.tsx +54 -5
- package/src/react/index.ts +8 -0
- package/src/react/master-lock/MasterLockDialog.tsx +13 -9
- package/src/react/master-lock/MasterLockProvider.spec.tsx +17 -0
- package/src/react/master-lock/MasterLockProvider.tsx +16 -19
- package/src/react/media/MediaUploadControl.tsx +8 -29
- package/src/react/media/fileUploadClient.ts +7 -17
- package/src/react/media/hlsSource.ts +4 -1
- package/src/react/media/index.ts +0 -16
- package/src/react/sessionAuth.ts +2 -3
- package/src/scriptsResolve.spec.ts +66 -0
- package/src/styles-areas/activity-bar.css +1 -1
- package/src/styles-areas/auth.css +1 -1
- package/src/styles-areas/charts.css +1 -1
- package/src/styles-areas/chat.css +1 -1
- package/src/styles-areas/core.css +1 -1
- package/src/styles-areas/data-table.css +1 -1
- package/src/styles-areas/disk-usage.css +1 -1
- package/src/styles-areas/fields.css +1 -1
- package/src/styles-areas/master-detail.css +1 -1
- package/src/styles-areas/master-lock.css +108 -2
- package/src/styles-areas/media-gallery.css +1 -1
- package/src/styles-areas/media.css +1 -17
- package/src/styles-areas/nav.css +1 -1
- package/src/styles-areas/notifications.css +1 -1
- package/src/styles-areas/spreadsheet.css +1 -1
- package/src/styles-areas/stats.css +1 -1
- package/src/styles-areas/wizard.css +1 -1
- package/src/styles-areas/workbook-viewer.css +1 -1
- package/src/styles-utilities.css +0 -16
- package/src/testing/_noBun.ts +12 -0
- package/src/verifyGraph.spec.ts +11 -0
- package/dist/react/media/PrivateIndexView.d.ts +0 -6
- package/dist/react/media/PrivateIndexView.d.ts.map +0 -1
- package/dist/react/media/PrivateIndexView.js +0 -44
- package/dist/react/media/PrivateIndexView.js.map +0 -1
- package/dist/react/media/PrivateModeBadge.d.ts +0 -17
- package/dist/react/media/PrivateModeBadge.d.ts.map +0 -1
- package/dist/react/media/PrivateModeBadge.js +0 -24
- package/dist/react/media/PrivateModeBadge.js.map +0 -1
- package/dist/react/media/privateClient.d.ts +0 -34
- package/dist/react/media/privateClient.d.ts.map +0 -1
- package/dist/react/media/privateClient.js +0 -36
- package/dist/react/media/privateClient.js.map +0 -1
- package/dist/react/media/usePrivateUnlock.d.ts +0 -25
- package/dist/react/media/usePrivateUnlock.d.ts.map +0 -1
- package/dist/react/media/usePrivateUnlock.js +0 -68
- package/dist/react/media/usePrivateUnlock.js.map +0 -1
- package/src/react/media/PrivateIndexView.tsx +0 -113
- package/src/react/media/PrivateModeBadge.tsx +0 -50
- package/src/react/media/privateClient.spec.ts +0 -113
- package/src/react/media/privateClient.ts +0 -82
- package/src/react/media/usePrivateUnlock.ts +0 -132
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Does an app's BUILT CSS style every cursedbelt class its BUILT JS uses? — the completeness
|
|
4
|
+
* check the per-area stylesheets need before any app may adopt them (task 079).
|
|
5
|
+
*
|
|
6
|
+
* ── Why adopting the areas was blocked on this ──────────────────────────────────────────
|
|
7
|
+
* `cursedbelt/styles-areas/*.css` lets an app import `core` plus the areas it renders instead
|
|
8
|
+
* of the 135 KB union (`scripts/generateAreaStyles.ts` has the numbers). The catch is the one
|
|
9
|
+
* `apps/flix/scripts/check-belt-styles.ts` was built for: under-select ONE area and the app
|
|
10
|
+
* typechecks, tests and builds green while a component renders unstyled. A byte count cannot
|
|
11
|
+
* tell "the right ten areas" from "nine of them" — a 4 KB miss is invisible in it.
|
|
12
|
+
*
|
|
13
|
+
* ── How it decides, with no new source of truth ─────────────────────────────────────────
|
|
14
|
+
* 1. The UNION — every utility cursedbelt's components use — is this package's own
|
|
15
|
+
* `src/styles-utilities.css`. Its class selectors are the universe.
|
|
16
|
+
* 2. What the app's JS references: every whitespace/quote-delimited token in the app's built
|
|
17
|
+
* `*.js` that is ALSO a union class. (Tailwind's extractor works the same way; a class
|
|
18
|
+
* name survives minification because it is a string.)
|
|
19
|
+
* 3. What the app's CSS styles: every class selector in the app's built `*.css`.
|
|
20
|
+
* 4. (2) minus (3) is a class cursedbelt draws with and this app ships no rule for. Each one is
|
|
21
|
+
* reported with the AREA that carries it (`src/styles-areas/*.css`), which is the fix.
|
|
22
|
+
*
|
|
23
|
+
* A false positive is possible — an app string that happens to equal a utility name and is not
|
|
24
|
+
* used as a class — and it is the cheap direction: adding the area costs bytes, missing one
|
|
25
|
+
* costs a broken screen on a live hostname. A class that appears in NO area is excluded (the
|
|
26
|
+
* area split already proves core-plus-every-area equals the union, `src/stylesAreas.spec.ts`).
|
|
27
|
+
*
|
|
28
|
+
* Usage, from an app, after its build:
|
|
29
|
+
* bun node_modules/cursedbelt/scripts/checkAreaStyles.ts dist
|
|
30
|
+
* Exit 0 = complete, 1 = a class is unstyled (listed with its area), 2 = could not measure
|
|
31
|
+
* (no JS or no CSS under the directory — a check that measured nothing has not passed).
|
|
32
|
+
*/
|
|
33
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
34
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
36
|
+
|
|
37
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
38
|
+
/** This package's `src`, from `scripts/` (source) or `dist/scripts/` (compiled). */
|
|
39
|
+
const SRC = [resolve(HERE, '../src'), resolve(HERE, '../../src')].find((d) =>
|
|
40
|
+
existsSync(join(d, 'styles-utilities.css')),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
/** Undo CSS identifier escaping: `\:` → `:`, `\32 xl` → `2xl`. */
|
|
44
|
+
function unescapeIdent(raw: string): string {
|
|
45
|
+
return raw.replace(/\\([0-9a-fA-F]{1,6}) ?|\\(.)/g, (_m, hex: string | undefined, ch: string | undefined) =>
|
|
46
|
+
hex ? String.fromCodePoint(Number.parseInt(hex, 16)) : (ch ?? ''),
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Every class selector in a stylesheet, unescaped. Comments are dropped first. */
|
|
51
|
+
export function classesInCss(css: string): Set<string> {
|
|
52
|
+
const out = new Set<string>();
|
|
53
|
+
const code = css.replace(/\/\*[\s\S]*?\*\//g, '');
|
|
54
|
+
// Only selector text — the part before each `{` — so a `.5rem` in a declaration never counts.
|
|
55
|
+
for (const selector of code.split('{').map((chunk) => chunk.slice(chunk.lastIndexOf('}') + 1).split(';').pop() ?? '')) {
|
|
56
|
+
for (const m of selector.matchAll(/\.((?:\\[0-9a-fA-F]{1,6} ?|\\.|[A-Za-z0-9_-])+)/g)) {
|
|
57
|
+
const name = unescapeIdent(m[1] as string);
|
|
58
|
+
if (/^[0-9]/.test(name) && !/[a-z]/i.test(name)) continue; // `.5` in a stray number
|
|
59
|
+
out.add(name);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Every whitespace/quote/backtick-delimited token in a JS bundle. */
|
|
66
|
+
export function tokensInJs(js: string): Set<string> {
|
|
67
|
+
return new Set(js.split(/[\s"'`]+/).filter((t) => t.length > 0 && t.length < 200));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function filesUnder(dir: string, ext: string, out: string[] = []): string[] {
|
|
71
|
+
for (const entry of readdirSync(dir)) {
|
|
72
|
+
if (entry === 'node_modules') continue;
|
|
73
|
+
const full = join(dir, entry);
|
|
74
|
+
if (statSync(full).isDirectory()) filesUnder(full, ext, out);
|
|
75
|
+
else if (entry.endsWith(ext)) out.push(full);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface AreaCheckResult {
|
|
81
|
+
/** Belt classes the JS uses and the CSS does not style → the area that carries each. */
|
|
82
|
+
missing: { className: string; area: string }[];
|
|
83
|
+
jsFiles: number;
|
|
84
|
+
cssFiles: number;
|
|
85
|
+
/** How many belt classes the JS references — a zero here means the check saw nothing. */
|
|
86
|
+
referenced: number;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The measurement, over explicit texts — what the spec drives. */
|
|
90
|
+
export function checkAreaStyles(
|
|
91
|
+
input: { js: string[]; css: string[] },
|
|
92
|
+
belt: { union: string; areas: Record<string, string> },
|
|
93
|
+
): Omit<AreaCheckResult, 'jsFiles' | 'cssFiles'> {
|
|
94
|
+
const union = classesInCss(belt.union);
|
|
95
|
+
const areaOf = new Map<string, string>();
|
|
96
|
+
for (const [area, css] of Object.entries(belt.areas)) {
|
|
97
|
+
for (const name of classesInCss(css)) if (!areaOf.has(name)) areaOf.set(name, area);
|
|
98
|
+
}
|
|
99
|
+
const emitted = new Set<string>();
|
|
100
|
+
for (const css of input.css) for (const name of classesInCss(css)) emitted.add(name);
|
|
101
|
+
const referenced = new Set<string>();
|
|
102
|
+
for (const js of input.js) for (const token of tokensInJs(js)) if (union.has(token) && areaOf.has(token)) referenced.add(token);
|
|
103
|
+
const missing = [...referenced]
|
|
104
|
+
.filter((name) => !emitted.has(name))
|
|
105
|
+
.sort()
|
|
106
|
+
.map((className) => ({ className, area: areaOf.get(className) as string }));
|
|
107
|
+
return { missing, referenced: referenced.size };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Read this package's union + area sheets. */
|
|
111
|
+
export function readBeltSheets(src: string | undefined = SRC): { union: string; areas: Record<string, string> } {
|
|
112
|
+
if (!src) throw new Error("cursedbelt's src/styles-utilities.css is not beside this script — the package is incomplete");
|
|
113
|
+
const areasDir = join(src, 'styles-areas');
|
|
114
|
+
const areas: Record<string, string> = {};
|
|
115
|
+
for (const file of readdirSync(areasDir)) if (file.endsWith('.css')) areas[basename(file, '.css')] = readFileSync(join(areasDir, file), 'utf8');
|
|
116
|
+
return { union: readFileSync(join(src, 'styles-utilities.css'), 'utf8'), areas };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function checkDist(distDir: string): AreaCheckResult {
|
|
120
|
+
const jsFiles = filesUnder(distDir, '.js');
|
|
121
|
+
const cssFiles = filesUnder(distDir, '.css');
|
|
122
|
+
const result = checkAreaStyles(
|
|
123
|
+
{ js: jsFiles.map((f) => readFileSync(f, 'utf8')), css: cssFiles.map((f) => readFileSync(f, 'utf8')) },
|
|
124
|
+
readBeltSheets(),
|
|
125
|
+
);
|
|
126
|
+
return { ...result, jsFiles: jsFiles.length, cssFiles: cssFiles.length };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (import.meta.main) {
|
|
130
|
+
const dir = resolve(process.argv[2] ?? 'dist');
|
|
131
|
+
if (!existsSync(dir)) {
|
|
132
|
+
console.error(`✗ ${dir} does not exist — build the app first. A check that measured nothing has not passed.`);
|
|
133
|
+
process.exit(2);
|
|
134
|
+
}
|
|
135
|
+
const result = checkDist(dir);
|
|
136
|
+
if (result.jsFiles === 0 || result.cssFiles === 0) {
|
|
137
|
+
console.error(`✗ ${dir} holds ${result.jsFiles} .js and ${result.cssFiles} .css file(s) — nothing to compare.`);
|
|
138
|
+
process.exit(2);
|
|
139
|
+
}
|
|
140
|
+
if (result.missing.length === 0) {
|
|
141
|
+
console.log(`✓ belt area styles complete: all ${result.referenced} cursedbelt classes the build references are styled (${result.jsFiles} js, ${result.cssFiles} css).`);
|
|
142
|
+
process.exit(0);
|
|
143
|
+
}
|
|
144
|
+
const byArea = new Map<string, string[]>();
|
|
145
|
+
for (const { className, area } of result.missing) byArea.set(area, [...(byArea.get(area) ?? []), className]);
|
|
146
|
+
console.error(`✗ ${result.missing.length} cursedbelt class(es) the built JS uses have NO rule in the built CSS.`);
|
|
147
|
+
console.error(' Import the area that carries them (after core) — `@import "cursedbelt/styles-areas/<area>.css";`:');
|
|
148
|
+
for (const [area, names] of [...byArea].sort()) console.error(` ${area}.css ← ${names.slice(0, 8).join(' ')}${names.length > 8 ? ` … +${names.length - 8}` : ''}`);
|
|
149
|
+
process.exit(1);
|
|
150
|
+
}
|
|
@@ -791,6 +791,14 @@ describe('no-native-dialog', () => {
|
|
|
791
791
|
expect(check('no-native-dialog', prompted)).toHaveLength(0);
|
|
792
792
|
});
|
|
793
793
|
|
|
794
|
+
test("🔴 a sibling import with the Node `.js` extension is the belt one too (task 269's spelling)", () => {
|
|
795
|
+
// DeleteButton's own `import { confirm } from '../confirm.js'` was reported as a native
|
|
796
|
+
// dialog from the day every relative specifier gained its `.js` until 2026-09-23.
|
|
797
|
+
const source = `import { confirm, type ConfirmOptions } from '../confirm.js';
|
|
798
|
+
const ok = await confirm({ title: 'Delete?' });`;
|
|
799
|
+
expect(check('no-native-dialog', source)).toHaveLength(0);
|
|
800
|
+
});
|
|
801
|
+
|
|
794
802
|
test('🔴 a belt import does NOT excuse an explicit window.confirm in the same file', () => {
|
|
795
803
|
// The migration's half-done state: one call site converted, one left behind.
|
|
796
804
|
// Exempting the whole FILE on the strength of its import list is how that
|
|
@@ -1176,3 +1184,38 @@ describe('--exclude', () => {
|
|
|
1176
1184
|
});
|
|
1177
1185
|
});
|
|
1178
1186
|
});
|
|
1187
|
+
|
|
1188
|
+
describe('no-raw-password', () => {
|
|
1189
|
+
test('🔴 flags the COMPONENT form no-raw-inputs is blind to', () => {
|
|
1190
|
+
expect(check('no-raw-password', `<Input id="ml-current" type="password" />`)).toHaveLength(1);
|
|
1191
|
+
expect(check('no-raw-inputs', `<Input id="ml-current" type="password" />`)).toHaveLength(0);
|
|
1192
|
+
});
|
|
1193
|
+
|
|
1194
|
+
test('flags a raw input, a JSX expression, a props object and template-literal HTML', () => {
|
|
1195
|
+
expect(check('no-raw-password', `<input type='password' />`)).toHaveLength(1);
|
|
1196
|
+
expect(check('no-raw-password', `<TextField type={"password"} />`)).toHaveLength(1);
|
|
1197
|
+
expect(check('no-raw-password', `const field = { name: 'pw', type: 'password' };`)).toHaveLength(1);
|
|
1198
|
+
expect(check('no-raw-password', 'const html = `<input name="pw" type="password">`;')).toHaveLength(1);
|
|
1199
|
+
});
|
|
1200
|
+
|
|
1201
|
+
test("passes SecretInput's own toggle — a ternary IS the eye", () => {
|
|
1202
|
+
expect(check('no-raw-password', ` type={shown ? 'text' : 'password'}`)).toHaveLength(0);
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
test('passes an ignore-commented line, same line or the block above', () => {
|
|
1206
|
+
expect(
|
|
1207
|
+
check('no-raw-password', `<input type="password" /> {/* guardrails-ignore no-raw-password: server page, own eye */}`),
|
|
1208
|
+
).toHaveLength(0);
|
|
1209
|
+
expect(
|
|
1210
|
+
check(
|
|
1211
|
+
'no-raw-password',
|
|
1212
|
+
`// guardrails-ignore no-raw-password: server-rendered, carries its own reveal button
|
|
1213
|
+
const html = \`<input type="password">\`;`,
|
|
1214
|
+
),
|
|
1215
|
+
).toHaveLength(0);
|
|
1216
|
+
});
|
|
1217
|
+
|
|
1218
|
+
test('prose in a comment is not a field', () => {
|
|
1219
|
+
expect(check('no-raw-password', `// never write type="password" by hand`)).toHaveLength(0);
|
|
1220
|
+
});
|
|
1221
|
+
});
|
|
@@ -122,6 +122,10 @@
|
|
|
122
122
|
// `--reimplements <Name,…>` from `fleet/standards/CURSEDBELT_INVENTORY.md`; with no
|
|
123
123
|
// list the rule is inert rather than guessing.
|
|
124
124
|
//
|
|
125
|
+
// no-raw-password — owner, 2026-09-18: every password field has an eye. Flags
|
|
126
|
+
// `type="password"` on ANY tag or props object (the `<Input type="password">` form that
|
|
127
|
+
// `no-raw-inputs` cannot see) and in template-literal HTML. Use `SecretInput`.
|
|
128
|
+
//
|
|
125
129
|
// Opt-out (mirrors this repo's existing `biome-ignore lint/<rule>: <reason>` convention —
|
|
126
130
|
// see e.g. packages/cursedbelt/src/react/components/ButtonGroup.tsx): put a same-shaped
|
|
127
131
|
// line comment as the FIRST thing inside the tag's attribute list (for `no-raw-action-button`
|
|
@@ -1487,7 +1491,9 @@ function importsBeltDialog(source: string, fn: string): boolean {
|
|
|
1487
1491
|
// static, from the package
|
|
1488
1492
|
new RegExp(`import\\s*${named}\\s*from\\s*${spec}`, "s").test(source) ||
|
|
1489
1493
|
// static, from a sibling inside cursedbelt itself
|
|
1490
|
-
|
|
1494
|
+
// (with or without the `.js` Node ESM needs — task 269 added it to every relative
|
|
1495
|
+
// specifier, and this pattern then reported `DeleteButton`'s own belt `confirm`)
|
|
1496
|
+
new RegExp(`import\\s*${named}\\s*from\\s*['"][./]+(?:react/)?${fn}(?:\\.[jt]sx?)?['"]`, "s").test(source) ||
|
|
1491
1497
|
// dynamic — `const { prompt } = await import('cursedbelt/react/prompt')`
|
|
1492
1498
|
new RegExp(`${named}\\s*=\\s*(?:await\\s+)?import\\(\\s*${spec}`, "s").test(source) ||
|
|
1493
1499
|
// and its type-only companion, `typeof import('…').prompt`
|
|
@@ -1626,6 +1632,44 @@ const noFastTooltip: Rule = {
|
|
|
1626
1632
|
},
|
|
1627
1633
|
};
|
|
1628
1634
|
|
|
1635
|
+
// ── no-raw-password (owner, 2026-09-18: "all passwords need eye icon") ─────────
|
|
1636
|
+
// A password field the person typing into cannot reveal. `SecretInput` has carried the eye
|
|
1637
|
+
// (and an opt-out copy button, `showCopy={false}` for a login form) the whole time, and on
|
|
1638
|
+
// 2026-09-19 twenty-eight fields across the fleet still typed into the dark — three of them in
|
|
1639
|
+
// cursedbelt's OWN master-lock dialog. They accumulated under an enforced guardrails suite
|
|
1640
|
+
// because there was no rule about masked fields at all: `no-raw-inputs` is about bare TAGS, and
|
|
1641
|
+
// every one of the three belt fields was `<Input type="password">`, a component it never reads.
|
|
1642
|
+
//
|
|
1643
|
+
// So this rule is on the ATTRIBUTE, on any tag: a raw `<input>`, a component (`<Input`,
|
|
1644
|
+
// `<TextField`), a props object (`{ type: 'password' }`), and server-rendered HTML in a
|
|
1645
|
+
// template literal (`<input type="password">`), which is the one place a React primitive
|
|
1646
|
+
// cannot reach — that markup takes an eye of its own. `SecretInput`'s own
|
|
1647
|
+
// `type={shown ? 'text' : 'password'}` does not match: a ternary is a toggle, which is the
|
|
1648
|
+
// point. Comments are blanked first; specs are fixtures and are skipped.
|
|
1649
|
+
const PASSWORD_TYPE_RE =
|
|
1650
|
+
/\btype\s*=\s*(?:\{\s*)?["'`]password["'`]|\btype\s*:\s*["'`]password["'`]/;
|
|
1651
|
+
const PASSWORD_IGNORE_RE = /guardrails-ignore\s+no-raw-password\s*:/;
|
|
1652
|
+
|
|
1653
|
+
const noRawPassword: Rule = {
|
|
1654
|
+
name: "no-raw-password",
|
|
1655
|
+
description:
|
|
1656
|
+
"Owner, 2026-09-18: every password field has an eye. A `type=\"password\"` field the person cannot reveal — on any tag, component or props object — must be cursedbelt's `SecretInput` (`showCopy={false}` for a login form, `autoComplete` passed through). Server-rendered HTML that cannot import it carries its own reveal toggle and takes the per-line opt-out naming it.",
|
|
1657
|
+
check(filePath, source) {
|
|
1658
|
+
if (isTestSource(filePath.replace(/\\/g, "/"))) return [];
|
|
1659
|
+
const raws = source.split("\n");
|
|
1660
|
+
const scan = blankComments(source).split("\n");
|
|
1661
|
+
const out: Violation[] = [];
|
|
1662
|
+
for (const [i, line] of scan.entries()) {
|
|
1663
|
+
if (!PASSWORD_TYPE_RE.test(line)) continue;
|
|
1664
|
+
const raw = raws[i] ?? "";
|
|
1665
|
+
if (PASSWORD_IGNORE_RE.test(raw)) continue;
|
|
1666
|
+
if (PASSWORD_IGNORE_RE.test(raws.slice(Math.max(0, i - 3), i).join("\n"))) continue;
|
|
1667
|
+
out.push({ file: filePath, line: i + 1, snippet: raw.trim() });
|
|
1668
|
+
}
|
|
1669
|
+
return out;
|
|
1670
|
+
},
|
|
1671
|
+
};
|
|
1672
|
+
|
|
1629
1673
|
export const RULES: Record<string, Rule> = {
|
|
1630
1674
|
[noRawActionButton.name]: noRawActionButton,
|
|
1631
1675
|
[noRawOverflow.name]: noRawOverflow,
|
|
@@ -1644,6 +1688,7 @@ export const RULES: Record<string, Rule> = {
|
|
|
1644
1688
|
[noSelectAsActionMenu.name]: noSelectAsActionMenu,
|
|
1645
1689
|
[noNativeDialog.name]: noNativeDialog,
|
|
1646
1690
|
[noFastTooltip.name]: noFastTooltip,
|
|
1691
|
+
[noRawPassword.name]: noRawPassword,
|
|
1647
1692
|
};
|
|
1648
1693
|
|
|
1649
1694
|
// ── File-scoped allowances ──────────────────────────────────────────────────────
|
package/scripts/styleAreas.ts
CHANGED
|
@@ -419,7 +419,6 @@ const resolveRelative = (fromFile: string, specifier: string): string | null =>
|
|
|
419
419
|
const base = `/${stack.join('/')}`.replace(/\.js$/, '');
|
|
420
420
|
if (SOURCE.test(base)) return base;
|
|
421
421
|
for (const candidate of [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`]) {
|
|
422
|
-
// biome-ignore lint/nursery/noAwaitInLoop: synchronous existence check, no await here
|
|
423
422
|
if (Bun.file(candidate).size > 0) return candidate;
|
|
424
423
|
}
|
|
425
424
|
return null;
|
|
@@ -442,7 +441,7 @@ export const buildGraph = async (): Promise<Map<string, string[]>> => {
|
|
|
442
441
|
for (const match of text.matchAll(SPECIFIER)) {
|
|
443
442
|
const target = resolveRelative(file, match[1] as string);
|
|
444
443
|
// Outside `src/react` is outside every area — the utility scan never sees it.
|
|
445
|
-
if (target
|
|
444
|
+
if (target?.startsWith(`${REACT_DIR}/`) && !SPEC.test(target)) out.add(target);
|
|
446
445
|
}
|
|
447
446
|
graph.set(file, [...out]);
|
|
448
447
|
}),
|
|
@@ -92,14 +92,17 @@ const NO_STATIC_OPTIONAL_PEERS = [
|
|
|
92
92
|
},
|
|
93
93
|
] as const;
|
|
94
94
|
|
|
95
|
-
const OPTIONAL_PEERS = new Set(
|
|
96
|
-
Object.entries(
|
|
95
|
+
const OPTIONAL_PEERS = new Set([
|
|
96
|
+
...Object.entries(
|
|
97
97
|
(pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> })
|
|
98
98
|
.peerDependenciesMeta ?? {},
|
|
99
99
|
)
|
|
100
100
|
.filter(([, meta]) => meta?.optional === true)
|
|
101
101
|
.map(([name]) => name),
|
|
102
|
-
)
|
|
102
|
+
// Optional in every sense but npm's (task 2087): declared in `unpeered`, not peered only
|
|
103
|
+
// because npm cannot resolve them. The barrel must not reach them statically either.
|
|
104
|
+
...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
|
|
105
|
+
]);
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
108
|
* `import x from "pkg"` · `export … from "pkg"` · the side-effect-only `import "pkg";`.
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pkg from '../package.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `bundlerOnly.subpaths` in `package.json` is EXACTLY the set of export subpaths whose
|
|
8
|
+
* built graph imports a stylesheet — in both directions.
|
|
9
|
+
*
|
|
10
|
+
* Task 2087: every cursedbelt publish runs `tools/proveInstall.ts`, and Node refused 12 of its
|
|
11
|
+
* entries with `ERR_UNKNOWN_FILE_EXTENSION ".css"`. That is by design (a component that ships
|
|
12
|
+
* its own stylesheet needs a bundler), but a proof that is red on every publish trains people to
|
|
13
|
+
* ignore it. The fix is an excuse the package DECLARES rather than one the tool grants globally —
|
|
14
|
+
* and a declaration only stays true if something re-derives it. This does: every subpath is
|
|
15
|
+
* bundled with its packages external, and a subpath counts when its output has a CSS sibling (its
|
|
16
|
+
* own `.css` imports) or imports a package's `.css` (`@vidstack/…/theme.css`). A new CSS import
|
|
17
|
+
* on an undeclared subpath is red here, before it is red in a publish; a declared subpath that
|
|
18
|
+
* stopped importing CSS is red too, so the excuse cannot outlive its reason.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
22
|
+
|
|
23
|
+
type Reason = 'css' | 'directoryImport';
|
|
24
|
+
const declared = (reason: Reason): string[] =>
|
|
25
|
+
[...((pkg as { bundlerOnly?: Record<Reason, string[]> }).bundlerOnly?.[reason] ?? [])].sort();
|
|
26
|
+
|
|
27
|
+
/** A deep specifier into a package with NO `exports` map, naming a directory — Node ESM refuses it. */
|
|
28
|
+
const isDirectoryImport = (specifier: string): boolean => {
|
|
29
|
+
const parts = specifier.split('/');
|
|
30
|
+
const n = specifier.startsWith('@') ? 2 : 1;
|
|
31
|
+
if (parts.length <= n) return false;
|
|
32
|
+
const name = parts.slice(0, n).join('/');
|
|
33
|
+
const manifest = `${REPO}node_modules/${name}/package.json`;
|
|
34
|
+
if (!existsSync(manifest) || JSON.parse(readFileSync(manifest, 'utf8')).exports) return false;
|
|
35
|
+
const target = `${REPO}node_modules/${specifier}`;
|
|
36
|
+
return existsSync(target) && statSync(target).isDirectory();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
|
|
40
|
+
|
|
41
|
+
async function measure(): Promise<Record<Reason, string[]>> {
|
|
42
|
+
const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
|
|
43
|
+
const source = (target as { source?: string }).source;
|
|
44
|
+
return source && /\.tsx?$/.test(source) ? [[subpath, source.replace(/^\.\//, '')] as const] : [];
|
|
45
|
+
});
|
|
46
|
+
const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-bundler-only-${process.pid}`;
|
|
47
|
+
try {
|
|
48
|
+
const result = await Bun.build({
|
|
49
|
+
entrypoints: entries.map(([, source]) => `${REPO}${source}`),
|
|
50
|
+
root: REPO,
|
|
51
|
+
target: 'bun',
|
|
52
|
+
packages: 'external',
|
|
53
|
+
outdir,
|
|
54
|
+
});
|
|
55
|
+
if (!result.success) throw new Error(`could not bundle — a check that cannot measure is not a pass:\n${result.logs.join('\n')}`);
|
|
56
|
+
const emitted = new Set(result.outputs.map((o) => o.path));
|
|
57
|
+
const out: Record<Reason, string[]> = { css: [], directoryImport: [] };
|
|
58
|
+
for (const [subpath, source] of entries) {
|
|
59
|
+
const js = `${outdir}/${source.replace(/\.tsx?$/, '.js')}`;
|
|
60
|
+
const code = readFileSync(js, 'utf8');
|
|
61
|
+
const specifiers = [...code.matchAll(SPECIFIER)].map((m) => (m[1] ?? m[2]) as string);
|
|
62
|
+
if (emitted.has(js.replace(/\.js$/, '.css')) || specifiers.some((s) => s.endsWith('.css'))) out.css.push(subpath);
|
|
63
|
+
if (specifiers.some((s) => !s.startsWith('.') && isDirectoryImport(s))) out.directoryImport.push(subpath);
|
|
64
|
+
}
|
|
65
|
+
out.css.sort();
|
|
66
|
+
out.directoryImport.sort();
|
|
67
|
+
return out;
|
|
68
|
+
} finally {
|
|
69
|
+
rmSync(outdir, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
describe('bundlerOnly', () => {
|
|
74
|
+
it('declares exactly the subpaths that import a stylesheet, and exactly those that reach a directory import', async () => {
|
|
75
|
+
const measured = await measure();
|
|
76
|
+
// The measurement must have seen the cases it exists for, or it measured nothing.
|
|
77
|
+
expect(measured.css).toContain('./react/media');
|
|
78
|
+
expect(measured.directoryImport).toContain('./react/disk-usage');
|
|
79
|
+
for (const reason of ['css', 'directoryImport'] as const) {
|
|
80
|
+
expect({
|
|
81
|
+
reason,
|
|
82
|
+
undeclared: measured[reason].filter((s) => !declared(reason).includes(s)),
|
|
83
|
+
stale: declared(reason).filter((s) => !measured[reason].includes(s)),
|
|
84
|
+
}).toEqual({ reason, undeclared: [], stale: [] });
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('names only subpaths that exist', () => {
|
|
89
|
+
const exported = Object.keys(pkg.exports as Record<string, unknown>);
|
|
90
|
+
for (const reason of ['css', 'directoryImport'] as const) expect(declared(reason).filter((s) => !exported.includes(s))).toEqual([]);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('unpeered', () => {
|
|
95
|
+
it('is disjoint from peerDependencies — a package is peered or unpeered, never both', () => {
|
|
96
|
+
const unpeered = Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {});
|
|
97
|
+
expect(unpeered.length).toBeGreaterThan(0);
|
|
98
|
+
const peers = Object.keys((pkg as { peerDependencies?: Record<string, string> }).peerDependencies ?? {});
|
|
99
|
+
expect(unpeered.filter((name) => peers.includes(name))).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
@@ -20,6 +20,10 @@ const declared = new Set([
|
|
|
20
20
|
pkg.name,
|
|
21
21
|
...Object.keys((pkg as { dependencies?: Record<string, string> }).dependencies ?? {}),
|
|
22
22
|
...Object.keys((pkg as { peerDependencies?: Record<string, string> }).peerDependencies ?? {}),
|
|
23
|
+
// `unpeered` — imported by a subpath, declared, and kept OUT of peerDependencies only
|
|
24
|
+
// because npm cannot resolve them beside this package's own peers (task 2087; the block says
|
|
25
|
+
// why for each).
|
|
26
|
+
...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
|
|
23
27
|
]);
|
|
24
28
|
const builtin = new Set(builtinModules);
|
|
25
29
|
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { readFileSync, rmSync } from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import pkg from '../package.json';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 🔴 `docs/API-REFERENCE.md`'s "What you install" table is the ONE place an importing app learns
|
|
8
|
+
* that `cursedbelt/react/charts` needs `recharts` — and this spec is what keeps it true.
|
|
9
|
+
*
|
|
10
|
+
* Task 104 (2026-09-17): *"`cursedbelt/react/charts` hard-fails the build without `recharts`,
|
|
11
|
+
* and the error names a `__vite-optional-peer-dep` module rather than the package."* The fix is
|
|
12
|
+
* one install; the cost was entirely in not knowing. The table says what to install, but it was
|
|
13
|
+
* hand-written — and measured the day this spec arrived it had already missed a subpath:
|
|
14
|
+
* `./react/media-gallery` statically reaches `@glideapps/glide-data-grid`, `@vidstack/react` and
|
|
15
|
+
* `marked`, and no row said so. (`peers-by-subpath.json` died of exactly this — task 272.)
|
|
16
|
+
*
|
|
17
|
+
* So: every public `./react/*` subpath is bundled with its packages left external, the OPTIONAL
|
|
18
|
+
* peers each one imports STATICALLY are read out of the bundle, and each must be named in that
|
|
19
|
+
* subpath's row of the table. A static optional peer is the case that breaks a build; a lazy
|
|
20
|
+
* `await import()` one breaks only the feature that loads it, and is the documented shape to
|
|
21
|
+
* copy, so it is not required here (a row may still name it).
|
|
22
|
+
*
|
|
23
|
+
* A row names a peer when its second cell carries the name in backticks, or a `@scope/*` set
|
|
24
|
+
* that covers it (the `@codemirror/*` set).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const REPO = fileURLToPath(new URL('..', import.meta.url));
|
|
28
|
+
const DOC = `${REPO}docs/API-REFERENCE.md`;
|
|
29
|
+
const HEADING = '### What you install, and what you don\'t';
|
|
30
|
+
|
|
31
|
+
const OPTIONAL = new Set([
|
|
32
|
+
...Object.entries(
|
|
33
|
+
(pkg as { peerDependenciesMeta?: Record<string, { optional?: boolean }> }).peerDependenciesMeta ??
|
|
34
|
+
{},
|
|
35
|
+
)
|
|
36
|
+
.filter(([, meta]) => meta?.optional === true)
|
|
37
|
+
.map(([name]) => name),
|
|
38
|
+
// `unpeered` (task 2087): optional peers npm cannot resolve, so not in peerDependencies —
|
|
39
|
+
// an app still has to install them, so the table still has to name them.
|
|
40
|
+
...Object.keys((pkg as { unpeered?: { packages?: Record<string, string> } }).unpeered?.packages ?? {}),
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
/** Same matcher as `barrelsReachNoOptionalPeer.spec.ts` — anchored on `from`, never on a dynamic import. */
|
|
44
|
+
const STATIC_SPECIFIER = /\bfrom\s*["']([^"'\n]+)["']|^\s*import\s*["']([^"'\n]+)["'];?\s*$/gm;
|
|
45
|
+
|
|
46
|
+
const packageOf = (specifier: string): string => {
|
|
47
|
+
const parts = specifier.split('/');
|
|
48
|
+
return (specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]) ?? specifier;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** `./react/charts` → the optional peers its bundle imports statically. */
|
|
52
|
+
async function staticOptionalPeersBySubpath(): Promise<Map<string, string[]>> {
|
|
53
|
+
const entries = Object.entries(pkg.exports as Record<string, unknown>).flatMap(([subpath, target]) => {
|
|
54
|
+
const source = (target as { source?: string }).source;
|
|
55
|
+
return subpath.startsWith('./react/') && source && /\.tsx?$/.test(source) ? [[subpath, source] as const] : [];
|
|
56
|
+
});
|
|
57
|
+
const outdir = `${process.env.TMPDIR ?? '/tmp'}/cursedbelt-optional-peers-${process.pid}`;
|
|
58
|
+
try {
|
|
59
|
+
const result = await Bun.build({
|
|
60
|
+
entrypoints: entries.map(([, source]) => `${REPO}${source.replace(/^\.\//, '')}`),
|
|
61
|
+
root: `${REPO}src`,
|
|
62
|
+
target: 'bun',
|
|
63
|
+
packages: 'external',
|
|
64
|
+
outdir,
|
|
65
|
+
});
|
|
66
|
+
if (!result.success) throw new Error(`could not bundle the subpaths — a check that cannot measure is not a pass:\n${result.logs.join('\n')}`);
|
|
67
|
+
const out = new Map<string, string[]>();
|
|
68
|
+
for (const [subpath, source] of entries) {
|
|
69
|
+
const emitted = `${outdir}/${source.replace(/^\.\/src\//, '').replace(/\.tsx?$/, '.js')}`;
|
|
70
|
+
const text = readFileSync(emitted, 'utf8');
|
|
71
|
+
const found = new Set<string>();
|
|
72
|
+
for (const m of text.matchAll(STATIC_SPECIFIER)) {
|
|
73
|
+
const specifier = m[1] ?? m[2];
|
|
74
|
+
if (specifier && !specifier.startsWith('.') && OPTIONAL.has(packageOf(specifier))) found.add(packageOf(specifier));
|
|
75
|
+
}
|
|
76
|
+
out.set(subpath, [...found].sort());
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
} finally {
|
|
80
|
+
rmSync(outdir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The table's rows: every `cursedbelt/react/<name>` its first cell names (`…/y` continues the last), → its second cell. */
|
|
85
|
+
export function tableRows(markdown: string): Map<string, string> {
|
|
86
|
+
const start = markdown.indexOf(HEADING);
|
|
87
|
+
if (start < 0) throw new Error(`${DOC} has no "${HEADING}" section — the table this spec keeps honest is gone`);
|
|
88
|
+
const rows = new Map<string, string>();
|
|
89
|
+
for (const line of markdown.slice(start).split('\n').slice(1)) {
|
|
90
|
+
if (line.startsWith('#')) break;
|
|
91
|
+
if (!line.startsWith('|')) continue;
|
|
92
|
+
const [first = '', second = ''] = line.split('|').slice(1, 3);
|
|
93
|
+
for (const m of first.matchAll(/`cursedbelt\/(react\/[\w/-]+)`/g)) rows.set(`./${m[1]}`, second);
|
|
94
|
+
for (const m of first.matchAll(/`…\/([\w-]+)`/g)) rows.set(`./react/${m[1]}`, second);
|
|
95
|
+
}
|
|
96
|
+
return rows;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Does this cell name `peer` — literally, or through a `@scope/*` set? */
|
|
100
|
+
export function names(cell: string, peer: string): boolean {
|
|
101
|
+
if (cell.includes(`\`${peer}\``)) return true;
|
|
102
|
+
const scope = peer.startsWith('@') ? peer.split('/')[0] : null;
|
|
103
|
+
return scope !== null && cell.includes(`\`${scope}/*\``);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
describe("the docs' what-you-install table", () => {
|
|
107
|
+
it('🔴 fails on a subpath whose static optional peer no row names — the task-104 shape', () => {
|
|
108
|
+
const rows = tableRows(`${HEADING}\n| import this | also \`bun add\` this |\n| --- | --- |\n| \`cursedbelt/react/code-editor\` | the \`@codemirror/*\` set |\n`);
|
|
109
|
+
expect(names(rows.get('./react/code-editor') ?? '', '@codemirror/view')).toBe(true);
|
|
110
|
+
expect(rows.get('./react/charts')).toBeUndefined();
|
|
111
|
+
expect(names('`marked`', 'recharts')).toBe(false);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('names every optional peer each ./react/* subpath imports statically', async () => {
|
|
115
|
+
const rows = tableRows(readFileSync(DOC, 'utf8'));
|
|
116
|
+
const bySubpath = await staticOptionalPeersBySubpath();
|
|
117
|
+
// The measurement must have seen the case this exists for, or it measured nothing.
|
|
118
|
+
expect(bySubpath.get('./react/charts')).toContain('recharts');
|
|
119
|
+
const missing: string[] = [];
|
|
120
|
+
for (const [subpath, peers] of bySubpath) {
|
|
121
|
+
for (const peer of peers) {
|
|
122
|
+
if (!names(rows.get(subpath) ?? '', peer)) missing.push(`cursedbelt/${subpath.slice(2)} → ${peer}`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
expect(missing, `add these to the table in ${DOC}`).toEqual([]);
|
|
126
|
+
});
|
|
127
|
+
});
|
package/src/react/chat/index.ts
CHANGED
|
@@ -6,6 +6,14 @@ export { MessageScroller, type MessageScrollerProps } from './MessageScroller.js
|
|
|
6
6
|
export { MessageThread, type MessageThreadProps } from './MessageThread.js';
|
|
7
7
|
export { PresenceRoster, type PresenceRosterProps } from './PresenceRoster.js';
|
|
8
8
|
export { StatusMarker, type StatusMarkerProps } from './StatusMarker.js';
|
|
9
|
+
export {
|
|
10
|
+
PRESENCE_RETRY_MS,
|
|
11
|
+
type PresenceEntryLike,
|
|
12
|
+
type PresenceSubscription,
|
|
13
|
+
presenceUsers,
|
|
14
|
+
type UsePresenceOptions,
|
|
15
|
+
usePresence,
|
|
16
|
+
} from './usePresence.js';
|
|
9
17
|
export type {
|
|
10
18
|
ChatAttachment,
|
|
11
19
|
ChatAuthor,
|