@hachej/boring-ui-kit 0.4.0 → 0.4.1
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/bin/boring-ui-blocks.mjs +286 -0
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +144 -6
- package/package.json +6 -2
- package/r/app-shell.json +8 -8
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +10 -10
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +5 -5
- package/r/file-tree.json +5 -5
- package/r/html-viewer.json +3 -3
- package/r/markdown-editor.json +3 -3
- package/r/pi-ambient.json +7 -7
- package/r/pi-app.json +13 -18
- package/r/pi-chat.json +25 -13
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/registry.json +84 -78
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +6 -6
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The kit's one command: an app enforces the kit rules with it and copies no script. The contract (commands, flags, output, exit codes)
|
|
3
|
+
// is documented once, in registry/README.md, "Updating installed blocks".
|
|
4
|
+
//
|
|
5
|
+
// npx boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <app web dir>] [--json]
|
|
6
|
+
// npx boring-ui-blocks update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>]
|
|
7
|
+
// npx boring-ui-blocks add <shadcn add arguments...> [--manifest <path>] [--cwd <dir>]
|
|
8
|
+
// npx boring-ui-blocks manifest [--manifest <path>] [--cwd <dir>]
|
|
9
|
+
//
|
|
10
|
+
// `check` writes nothing. `add`, `update` and `manifest` write the shadcn files they install and the manifest, nothing else.
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { existsSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
// BORING-UI-1 rule engine, one implementation: this repository's `scripts/check-design.mjs` imports `scanDesign` (DESIGN_RULES) from
|
|
18
|
+
// here for the registry and the kit; `check --design` applies APP_RULES to an app's own code. Lexical guard, not a CSS cascade or data-flow
|
|
19
|
+
// proof. Layout metrics (gaps, widths, grid tracks, 44px touch targets) match no rule.
|
|
20
|
+
const shadow = /(?<![\w-])shadow-(?:xs|sm|md|lg|xl|2xl)(?![\w-])|\b(?:box-shadow|boxShadow)\s*[:=]\s*['"]?(?!\s*(?:var\(|none\b|inherit\b))[0-9.-][^;\n"']*/g;
|
|
21
|
+
const radius = /(?<![\w-])rounded(?:-(?:sm|md|lg|xl|2xl|3xl|4xl|full)|-\[(?!var\(|--)[^\]]+\])?(?![\w-])|\b(?:border-radius|borderRadius)\s*:\s*['"]?(?!\s*(?:var\(|inherit\b))(?:[0-9.]|calc\()[^;\n"']*/g;
|
|
22
|
+
const borderWidth = /(?<![\w-])border(?:-[trblxyse])?(?:-(?:\d+|\[(?:\d|\.)[^\]]*\]))?(?![\w-]|["']?\s*:)|\b(?:border(?:-[\w]+)?|borderWidth)\s*:\s*['"]?\d+(?:\.\d+)?px\b[^;\n"']*/g;
|
|
23
|
+
const color = /#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|oklch|oklab)\([^)]*\)|\b(?:bg|text|border|ring|fill|stroke|outline|decoration|shadow)-(?:white|black|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3})(?:\/\d+)?\b/g;
|
|
24
|
+
const font = /\bfont-\[(?!var\(|--)[^\]]+\]|\bfont-family\s*:\s*(?!\s*(?:var\(|inherit\b))[^;\n}]+|\bfontFamily\s*:\s*['"](?!var\()[^'"]+['"]|\bfont\s*:\s*[^;\n}]*\b(?:serif|monospace|system-ui)\b[^;\n}]*/g;
|
|
25
|
+
|
|
26
|
+
/** The repository's rules (registry blocks and kit sources). */
|
|
27
|
+
export const DESIGN_RULES = [
|
|
28
|
+
['shadow', shadow],
|
|
29
|
+
['radius', radius],
|
|
30
|
+
['border-width', borderWidth],
|
|
31
|
+
['color', color],
|
|
32
|
+
['font', font],
|
|
33
|
+
['motion', /\b(?:duration|delay)-\d+\b|\b(?:transition(?:-[\w-]+)?|animation(?:-[\w-]+)?|transitionDuration|animationDuration)\s*:\s*[^;\n}]*?\b\d*\.?\d+m?s\b[^;\n}]*/g],
|
|
34
|
+
['style', /\bradix(?:-[\w-]+)?\b/gi],
|
|
35
|
+
['primitive', /\b(?:function|const|class)\s+(?:Button|IconButton|ActionMenu|\w*Popover|\w*Tooltip|\w*Dialog|\w*Menu)\b|<(?:button|input|textarea|select)\b/g],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The app profile of the same engine. Blocks pick no radius, border or shadow at all (DESIGN_RULES); an app's own code may use the
|
|
40
|
+
* utilities that resolve to theme variables (`rounded-md`, `border`, `border-t`, `shadow-md`, `text-sm`) and fails on literals:
|
|
41
|
+
* colour literals and palette classes, arbitrary visual values, other border widths, font families, literal radius/shadow/font-size/
|
|
42
|
+
* letter-spacing/border-width declarations (a 1px border equals the `border` utility) and `dark:` variants (the theme's `.dark` tokens).
|
|
43
|
+
*/
|
|
44
|
+
const LENGTH = String.raw`(?<![\w.#-])(?!1px\b)\d*\.?\d+(?:px|rem|em)\b`;
|
|
45
|
+
export const APP_RULES = [
|
|
46
|
+
['color', color],
|
|
47
|
+
['arbitrary-value', /(?<![\w-])(?:bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|outline|fill|stroke|decoration|shadow|drop-shadow|inset-shadow|rounded(?:-[trblse]{1,2})?|font|tracking|leading|from|via|to|divide(?:-[xy])?|placeholder|caret|accent)-\[(?!var\(|--|#|(?:rgb|rgba|hsl|hsla|oklch|oklab)\()[^\]]+\]/g],
|
|
48
|
+
['border-width', /(?<![\w-])border(?:-[trblxyse])?-(?!0(?![\w.]))\d+(?![\w-])/g],
|
|
49
|
+
['border-width', new RegExp(String.raw`\b(?:border(?:-(?:top|right|bottom|left|inline|block)(?:-(?:start|end))?)?(?:-width)?|border(?:Top|Right|Bottom|Left)?Width|border(?:Top|Right|Bottom|Left)?)\s*:\s*['"]?(?=[^;\n}"']*?${LENGTH})[^;\n}"']*`, 'g')],
|
|
50
|
+
['font', /\bfont-family\s*:\s*(?!\s*(?:var\(|inherit\b))[^;\n}]+|\bfontFamily\s*:\s*['"](?!var\()[^'"]+['"]|\bfont\s*:\s*(?!\s*(?:var\(|inherit\b))[^;\n}]+/g],
|
|
51
|
+
['literal-value', /\b(?:border-radius|borderRadius|box-shadow|boxShadow|font-size|fontSize|letter-spacing|letterSpacing)\s*:\s*['"]?(?!\s*(?:var\(|calc\(\s*var\(|inherit\b|initial\b|unset\b|none\b|0(?![\w.])))(?=[^;\n}"']*\d)[^;\n}"']*/g],
|
|
52
|
+
['dark', /(?<![\w-])dark:[^\s"'`]+/g],
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** Findings `{ file, line, column, rule, value }` of `rules` in `text`; CSS block comments are ignored. */
|
|
56
|
+
export function scanDesign(file, text, rules = DESIGN_RULES) {
|
|
57
|
+
const findings = [];
|
|
58
|
+
const source = text.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, ' '));
|
|
59
|
+
for (const [rule, pattern] of rules) for (const match of source.matchAll(pattern)) {
|
|
60
|
+
const before = text.slice(0, match.index), line = before.split('\n').length;
|
|
61
|
+
findings.push({ file, line, column: match.index - before.lastIndexOf('\n'), rule, value: match[0] });
|
|
62
|
+
}
|
|
63
|
+
return findings;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Offset ranges of the CSS token definition blocks (`:root`, `.dark`, `@theme`, `@layer theme`): the only place an app's CSS holds values. */
|
|
67
|
+
function tokenBlocks(css) {
|
|
68
|
+
const ranges = [], stack = [];
|
|
69
|
+
let start = 0;
|
|
70
|
+
const token = prelude => /^@theme\b|^@layer\s+theme\b/.test(prelude)
|
|
71
|
+
|| prelude.split(',').every(part => /^(?::root|html|\.dark|:root\.dark|html\.dark|:root:not\(\.dark\)|\[data-theme[^\]]*\])$/.test(part.trim()));
|
|
72
|
+
for (let i = 0; i < css.length; i++) {
|
|
73
|
+
if (css[i] === '{') { stack.push({ open: i, token: token(css.slice(start, i).trim()) || stack.at(-1)?.token }); start = i + 1; }
|
|
74
|
+
else if (css[i] === '}') { const block = stack.pop(); if (block?.token && !stack.at(-1)?.token) ranges.push([block.open, i]); start = i + 1; }
|
|
75
|
+
else if (css[i] === ';') start = i + 1;
|
|
76
|
+
}
|
|
77
|
+
return ranges;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* An app file under APP_RULES. The repository's exception classes that apply to an app apply by construction, never through an app
|
|
82
|
+
* allowlist: Markdown is content (the command scans code and CSS only) and a CSS token definition block is the app's theme.
|
|
83
|
+
*/
|
|
84
|
+
export function scanAppDesign(file, text) {
|
|
85
|
+
if (!file.endsWith('.css')) return scanDesign(file, text, APP_RULES);
|
|
86
|
+
const source = text.replace(/\/\*[\s\S]*?\*\//g, match => match.replace(/[^\n]/g, ' '));
|
|
87
|
+
const ranges = tokenBlocks(source), starts = [0];
|
|
88
|
+
for (let i = 0; i < text.length; i++) if (text[i] === '\n') starts.push(i + 1);
|
|
89
|
+
return scanDesign(file, text, APP_RULES).filter(({ line, column }) => {
|
|
90
|
+
const offset = starts[line - 1] + column - 1;
|
|
91
|
+
return !ranges.some(([open, close]) => offset > open && offset < close);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const KIT = '@hachej/boring-ui-kit';
|
|
96
|
+
/** The shadcn CLI the registry items are built for (ARCHITECTURE.json `sourceDistribution.format`). */
|
|
97
|
+
export const SHADCN = 'shadcn@4.21.5';
|
|
98
|
+
/** sha256 of every generated shadcn file under the app's `ui` alias, written by `add`, `update` and `manifest`, verified by `check`. */
|
|
99
|
+
export const MANIFEST = 'SHADCN-GENERATED.json';
|
|
100
|
+
export const itemBase = version => `https://cdn.jsdelivr.net/npm/${KIT}@${version}/r`;
|
|
101
|
+
|
|
102
|
+
const posix = path => path.split(sep).join('/');
|
|
103
|
+
// sha256 of a file's text with line endings normalised (a checkout may convert them).
|
|
104
|
+
const sha256 = path => createHash('sha256').update(readFileSync(path, 'utf8').replace(/\r\n/g, '\n')).digest('hex');
|
|
105
|
+
|
|
106
|
+
/** Every file under `dir` (recursive, sorted), without node_modules and dot entries. */
|
|
107
|
+
function files(dir) {
|
|
108
|
+
if (!existsSync(dir)) return [];
|
|
109
|
+
return readdirSync(dir, { withFileTypes: true }).filter(entry => !entry.name.startsWith('.') && entry.name !== 'node_modules')
|
|
110
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
111
|
+
.flatMap(entry => entry.isDirectory() ? files(join(dir, entry.name)) : entry.isFile() ? [join(dir, entry.name)] : []);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The installed kit (its package.json version) and its registry items that copy files (`theme` has none). */
|
|
115
|
+
export function installedKit(app) {
|
|
116
|
+
const root = join(app, 'node_modules', KIT);
|
|
117
|
+
if (!existsSync(join(root, 'package.json'))) throw new Error(`${KIT} is not installed in ${app}: the kit version is read from node_modules/${KIT}/package.json`);
|
|
118
|
+
const { version } = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
|
|
119
|
+
if (!version || !existsSync(join(root, 'r'))) throw new Error(`${KIT}@${version ?? '?'} ships no registry items (node_modules/${KIT}/r)`);
|
|
120
|
+
const items = readdirSync(join(root, 'r')).filter(name => name.endsWith('.json') && name !== 'registry.json')
|
|
121
|
+
.map(name => JSON.parse(readFileSync(join(root, 'r', name), 'utf8'))).filter(item => item.files?.length);
|
|
122
|
+
return { version, items };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Where shadcn writes, from components.json `aliases`: an `@/` alias is `src/` when the app has one, else the app root. */
|
|
126
|
+
export function appPaths(app) {
|
|
127
|
+
const file = join(app, 'components.json');
|
|
128
|
+
if (!existsSync(file)) throw new Error(`no components.json in ${app}: run in the app's web directory or pass --cwd`);
|
|
129
|
+
const { aliases = {} } = JSON.parse(readFileSync(file, 'utf8'));
|
|
130
|
+
const dir = alias => join(app, existsSync(join(app, 'src')) ? 'src' : '', alias.replace(/^[@~]\//, ''));
|
|
131
|
+
const components = dir(aliases.components ?? '@/components');
|
|
132
|
+
return { components, ui: aliases.ui ? dir(aliases.ui) : join(components, 'ui') };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// The CLI may drop the `'use client'` directive (a non-RSC app) and an editor may change line endings: neither is an edit.
|
|
136
|
+
// The CLI also drops the leading comment of a file it writes. The rest of the file must match exactly.
|
|
137
|
+
const preamble = /^(?:\s*(?:\/\/[^\n]*|\/\*[\s\S]*?\*\/|(['"])use client\1;?))*\s*/;
|
|
138
|
+
const normal = text => text.replace(/\r\n/g, '\n').replace(preamble, '').trimEnd();
|
|
139
|
+
|
|
140
|
+
/** Each installed block (one of its files exists): its files, and those that differ from the registry item (`edited`) or are gone (`missing`). */
|
|
141
|
+
export function checkBlocks(app, components = appPaths(app).components) {
|
|
142
|
+
const { version, items } = installedKit(app);
|
|
143
|
+
const blocks = [];
|
|
144
|
+
for (const item of items) {
|
|
145
|
+
const paths = item.files.map(file => ({ file, path: join(components, file.target.replace(/^components\//, '')) }));
|
|
146
|
+
if (!paths.some(({ path }) => existsSync(path))) continue;
|
|
147
|
+
const edited = [], missing = [];
|
|
148
|
+
for (const { file, path } of paths) {
|
|
149
|
+
if (!existsSync(path)) missing.push(path);
|
|
150
|
+
else if (normal(readFileSync(path, 'utf8')) !== normal(file.content ?? '')) edited.push(path);
|
|
151
|
+
}
|
|
152
|
+
blocks.push({ name: item.name, files: paths.map(({ path }) => path), edited, missing });
|
|
153
|
+
}
|
|
154
|
+
return { version, blocks };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The generated shadcn files under the `ui` alias (`.js/.jsx/.ts/.tsx/.css`) and their sha256, by path relative to it. */
|
|
158
|
+
const uiHashes = ui => new Map(files(ui).filter(path => /\.(?:[jt]sx?|css)$/.test(path)).map(path => [posix(relative(ui, path)), { path, hash: sha256(path) }]));
|
|
159
|
+
|
|
160
|
+
/** (Re)writes the manifest (`manifest`, default SHADCN-GENERATED.json next to components.json): `files` is replaced, other keys are kept. */
|
|
161
|
+
export function writeManifest(app, manifest = MANIFEST) {
|
|
162
|
+
const { ui } = appPaths(app);
|
|
163
|
+
const path = resolve(app, manifest);
|
|
164
|
+
const hashes = Object.fromEntries([...uiHashes(ui)].map(([name, { hash }]) => [name, hash]));
|
|
165
|
+
const kept = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { generatedBy: 'boring-ui-blocks', dir: posix(relative(app, ui)) };
|
|
166
|
+
writeFileSync(path, `${JSON.stringify({ ...kept, files: hashes }, null, 2)}\n`);
|
|
167
|
+
return Object.keys(hashes).length;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Generated shadcn files edited by hand (`edited`), present but not in the manifest (`unrecorded`) or deleted (`missing`). */
|
|
171
|
+
export function checkUi(app, manifest = MANIFEST) {
|
|
172
|
+
const { ui } = appPaths(app);
|
|
173
|
+
const path = resolve(app, manifest);
|
|
174
|
+
const recorded = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')).files ?? {} : {};
|
|
175
|
+
const present = uiHashes(ui);
|
|
176
|
+
const found = [];
|
|
177
|
+
for (const [name, file] of present) {
|
|
178
|
+
if (!(name in recorded)) found.push({ rule: 'unrecorded', path: file.path });
|
|
179
|
+
else if (recorded[name] !== file.hash) found.push({ rule: 'edited', path: file.path });
|
|
180
|
+
}
|
|
181
|
+
for (const name of Object.keys(recorded)) if (!present.has(name)) found.push({ rule: 'missing', path: join(ui, name) });
|
|
182
|
+
return found;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Tokens-only findings in the app's own code under `dirs`, skipping generated shadcn files and installed block files. */
|
|
186
|
+
export function checkDesign(app, dirs, skip) {
|
|
187
|
+
const { ui } = appPaths(app);
|
|
188
|
+
const findings = [];
|
|
189
|
+
for (const dir of dirs) {
|
|
190
|
+
const base = resolve(app, dir);
|
|
191
|
+
if (!existsSync(base)) throw new Error(`--design ${dir}: no such directory in ${app}`);
|
|
192
|
+
for (const path of files(base)) {
|
|
193
|
+
if (!/\.(?:[cm]?[jt]sx?|css)$/.test(path) || skip.has(path) || path.startsWith(ui + sep)) continue;
|
|
194
|
+
findings.push(...scanAppDesign(posix(relative(app, path)), readFileSync(path, 'utf8')).sort((a, b) => a.line - b.line || a.column - b.column));
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
return findings;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Every rule the kit enforces in an app: `{ version, blocks, violations }`, each violation `{ check, rule, file, line?, column?, detail }`. */
|
|
201
|
+
export function check(app, design = [], manifest = MANIFEST) {
|
|
202
|
+
const { components } = appPaths(app);
|
|
203
|
+
const { version, blocks } = checkBlocks(app, components);
|
|
204
|
+
const rel = path => posix(relative(app, path));
|
|
205
|
+
const violations = [];
|
|
206
|
+
for (const block of blocks) {
|
|
207
|
+
for (const path of block.edited) violations.push({ check: 'blocks', rule: 'edited', file: rel(path), detail: block.name });
|
|
208
|
+
for (const path of block.missing) violations.push({ check: 'blocks', rule: 'missing', file: rel(path), detail: block.name });
|
|
209
|
+
}
|
|
210
|
+
if (design.length) for (const finding of checkDesign(app, design, new Set(blocks.flatMap(block => block.files)))) {
|
|
211
|
+
violations.push({ check: 'design', rule: finding.rule, file: finding.file, line: finding.line, column: finding.column, detail: finding.value });
|
|
212
|
+
}
|
|
213
|
+
for (const { rule, path } of checkUi(app, manifest)) violations.push({ check: 'ui', rule, file: rel(path), detail: posix(manifest) });
|
|
214
|
+
return { version, blocks: blocks.map(block => block.name), violations };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const USAGE = 'usage: boring-ui-blocks check [--design <dir>...] [--manifest <path>] [--cwd <dir>] [--json] | update [--version <v>] [--registry <url>] [--dry-run] [--manifest <path>] [--cwd <dir>] | add <shadcn add args...> [--manifest <path>] [--cwd <dir>] | manifest [--manifest <path>] [--cwd <dir>]';
|
|
218
|
+
const FLAGS = { check: ['design', 'manifest', 'json'], update: ['version', 'registry', 'dry-run', 'manifest'], manifest: ['manifest'] };
|
|
219
|
+
|
|
220
|
+
function parse(command, rest) {
|
|
221
|
+
const flags = { design: [], pass: [] };
|
|
222
|
+
for (let i = 0; i < rest.length; i++) {
|
|
223
|
+
const name = rest[i].startsWith('--') ? rest[i].slice(2) : null;
|
|
224
|
+
if (command === 'add' && name !== 'cwd' && name !== 'manifest') { flags.pass.push(rest[i]); continue; }
|
|
225
|
+
if (!name || !FLAGS[command]?.includes(name) && name !== 'cwd') throw new Error(`unknown argument ${rest[i]}`);
|
|
226
|
+
if (name === 'json') flags.json = true;
|
|
227
|
+
else if (name === 'dry-run') flags.dryRun = true;
|
|
228
|
+
else if (name === 'design') {
|
|
229
|
+
while (rest[i + 1] && !rest[i + 1].startsWith('--')) flags.design.push(rest[++i]);
|
|
230
|
+
if (!flags.design.length) throw new Error('--design needs at least one directory');
|
|
231
|
+
} else if (rest[i + 1] === undefined || rest[i + 1].startsWith('--')) throw new Error(`--${name} needs a value`);
|
|
232
|
+
else flags[name] = rest[++i];
|
|
233
|
+
}
|
|
234
|
+
return flags;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shadcnAdd(app, args, log) {
|
|
238
|
+
const argv = ['-y', SHADCN, 'add', ...args];
|
|
239
|
+
log(`npx ${argv.join(' ')}`);
|
|
240
|
+
const result = spawnSync('npx', argv, { cwd: app, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
241
|
+
return result.status ?? 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const line = v => `${v.check} ${v.rule} ${v.file}${v.line ? `:${v.line}:${v.column}` : ''} ${v.detail}`;
|
|
245
|
+
|
|
246
|
+
export function main(argv, cwd = process.cwd(), log = console.log, error = console.error) {
|
|
247
|
+
const [command, ...rest] = argv;
|
|
248
|
+
let flags;
|
|
249
|
+
try {
|
|
250
|
+
if (!['check', 'update', 'add', 'manifest'].includes(command)) throw new Error(command ? `unknown command ${command}` : 'missing command');
|
|
251
|
+
flags = parse(command, rest);
|
|
252
|
+
const app = resolve(cwd, flags.cwd ?? '.');
|
|
253
|
+
if (command === 'check') {
|
|
254
|
+
const manifest = flags.manifest ?? MANIFEST;
|
|
255
|
+
const result = check(app, flags.design, manifest);
|
|
256
|
+
if (flags.json) { log(JSON.stringify({ ok: !result.violations.length, kit: `${KIT}@${result.version}`, ...result }, null, 2)); return result.violations.length ? 1 : 0; }
|
|
257
|
+
for (const violation of result.violations) log(line(violation));
|
|
258
|
+
const counts = ['blocks', 'design', 'ui'].map(name => `${name} ${result.violations.filter(v => v.check === name).length}`).join(', ');
|
|
259
|
+
if (!result.violations.length) { log(`ok ${KIT}@${result.version}: ${result.blocks.length} block(s), design ${flags.design.length ? flags.design.join(' ') : 'not checked'}, ${posix(manifest)} matches`); return 0; }
|
|
260
|
+
log(`fail ${KIT}@${result.version}: ${result.violations.length} violation(s) (${counts}). Blocks and ui files are generated: move a change to an extension point or the theme, then run \`boring-ui-blocks update\`; fix design violations in the app, never the rule.`);
|
|
261
|
+
return 1;
|
|
262
|
+
}
|
|
263
|
+
if (command === 'manifest') { log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`); return 0; }
|
|
264
|
+
if (command === 'add') {
|
|
265
|
+
appPaths(app);
|
|
266
|
+
if (!flags.pass.length) throw new Error('add needs shadcn add arguments');
|
|
267
|
+
const status = shadcnAdd(app, flags.pass, log);
|
|
268
|
+
if (status === 0) log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`);
|
|
269
|
+
return status;
|
|
270
|
+
}
|
|
271
|
+
const { version: installed, blocks } = checkBlocks(app);
|
|
272
|
+
if (!blocks.length) throw new Error('no installed blocks to update');
|
|
273
|
+
const base = (flags.registry ?? itemBase(flags.version ?? installed)).replace(/\/$/, '');
|
|
274
|
+
const items = blocks.map(block => `${base}/${block.name}.json`);
|
|
275
|
+
if (flags.dryRun) { log(`npx -y ${SHADCN} add ${items.join(' ')} --overwrite --yes`); return 0; }
|
|
276
|
+
const status = shadcnAdd(app, [...items, '--overwrite', '--yes'], log);
|
|
277
|
+
if (status === 0) log(`${flags.manifest ?? MANIFEST}: ${writeManifest(app, flags.manifest)} file(s)`);
|
|
278
|
+
return status;
|
|
279
|
+
} catch (cause) {
|
|
280
|
+
if (flags?.json) log(JSON.stringify({ ok: false, error: cause.message }, null, 2));
|
|
281
|
+
else error(`error: ${cause.message}\n${USAGE}`);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) process.exitCode = main(process.argv.slice(2));
|
package/dist/remote-chat.d.ts
CHANGED
|
@@ -29,6 +29,24 @@ export interface RemoteChatOptions {
|
|
|
29
29
|
readonly baseDelayMs?: number;
|
|
30
30
|
readonly maxDelayMs?: number;
|
|
31
31
|
};
|
|
32
|
+
/**
|
|
33
|
+
* `websocket`: carry the watch and every call over one WebSocket to the same endpoint (`ws:`/`wss:` from the page), served by
|
|
34
|
+
* `createConversationSocketHandler` of `@hachej/boring-agent/chat-transport`. A Durable Object can then hibernate while the page
|
|
35
|
+
* is idle. Calls not yet answered when the socket drops are sent again unchanged (same request ID) on the next socket, and every
|
|
36
|
+
* socket begins with a complete view. If the first socket cannot open, the chat uses HTTP (`fetch`) instead. Default `http`.
|
|
37
|
+
*/
|
|
38
|
+
readonly transport?: 'http' | 'websocket';
|
|
39
|
+
/**
|
|
40
|
+
* WebSocket details. `open` creates the socket (default `new WebSocket(url, 'boring.chat')`); a browser cannot set headers on a
|
|
41
|
+
* WebSocket, so a host with bearer tokens offers its credential as another subprotocol here. `pingMs` (default 15000): a `ping`
|
|
42
|
+
* every interval; a socket silent for 2.5 intervals is treated as lost and reopened.
|
|
43
|
+
*/
|
|
44
|
+
readonly webSocket?: {
|
|
45
|
+
readonly open?: (url: URL) => WebSocket;
|
|
46
|
+
readonly pingMs?: number;
|
|
47
|
+
};
|
|
48
|
+
/** Host events the socket carries beside the chat (`broadcast` on the server, for example presence or the conversation list). */
|
|
49
|
+
readonly onEvent?: (event: unknown) => void;
|
|
32
50
|
}
|
|
33
51
|
export interface RemoteChat {
|
|
34
52
|
/** The members of the native conversation the chat controller uses, served by the remote host. Nothing else is pretended. */
|
package/dist/remote-chat.js
CHANGED
|
@@ -37,8 +37,31 @@ export async function createRemoteChat(options) {
|
|
|
37
37
|
target.searchParams.set(key, value);
|
|
38
38
|
return target;
|
|
39
39
|
};
|
|
40
|
+
// The transport in use: `websocket` until its first socket fails to open, then `http` for good.
|
|
41
|
+
let mode = options.transport ?? 'http', current, seq = 0;
|
|
42
|
+
/** Calls sent over a socket and not answered yet, by call ID: sent again unchanged on the next socket. */
|
|
43
|
+
const outbox = new Map();
|
|
44
|
+
function exchange(op, init, extra) {
|
|
45
|
+
const http = () => new Request(url(op, extra), init);
|
|
46
|
+
if (mode !== 'websocket')
|
|
47
|
+
return send(http());
|
|
48
|
+
const id = `c${++seq}`;
|
|
49
|
+
const text = JSON.stringify({ kind: 'call', id, op, params: extra, ...(typeof init.body === 'string' ? { body: JSON.parse(init.body) } : {}) });
|
|
50
|
+
return new Promise((resolve, reject) => { outbox.set(id, { text, http, resolve, reject }); try {
|
|
51
|
+
current?.send(text);
|
|
52
|
+
}
|
|
53
|
+
catch { /* sent again on the next socket */ } });
|
|
54
|
+
}
|
|
55
|
+
/** Leave the socket for HTTP: calls still waiting for a socket go over HTTP, unchanged (same request ID, so the host deduplicates). */
|
|
56
|
+
function fallBack() {
|
|
57
|
+
mode = 'http';
|
|
58
|
+
const pending = [...outbox.values()];
|
|
59
|
+
outbox.clear();
|
|
60
|
+
for (const call of pending)
|
|
61
|
+
send(call.http()).then(call.resolve, call.reject);
|
|
62
|
+
}
|
|
40
63
|
async function call(op, init = {}, extra = {}) {
|
|
41
|
-
const response = await
|
|
64
|
+
const response = await exchange(op, init, extra);
|
|
42
65
|
if (response.status === 402 && op === 'submit') {
|
|
43
66
|
const refused = await response.json().catch(() => ({}));
|
|
44
67
|
if (refused.reason === 'submission-refused')
|
|
@@ -50,13 +73,107 @@ export async function createRemoteChat(options) {
|
|
|
50
73
|
}
|
|
51
74
|
const post = (op, value) => call(op, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
|
|
52
75
|
const lookup = async (requestId) => (await call('submission', {}, { requestId })).record ?? undefined;
|
|
53
|
-
|
|
76
|
+
const ping = options.webSocket?.pingMs ?? 15_000;
|
|
77
|
+
const openSocket = options.webSocket?.open ?? ((target) => new WebSocket(target, 'boring.chat'));
|
|
78
|
+
function socketSource() {
|
|
79
|
+
return new Promise((ready, refuse) => {
|
|
80
|
+
const target = new URL(base);
|
|
81
|
+
target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
82
|
+
let socket;
|
|
83
|
+
try {
|
|
84
|
+
socket = openSocket(target);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
refuse(error);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
const frames = [];
|
|
91
|
+
let wake, closed = false, opened = false, heard = Date.now();
|
|
92
|
+
const notify = () => { const resume = wake; wake = undefined; resume?.(); };
|
|
93
|
+
const finish = () => {
|
|
94
|
+
if (closed)
|
|
95
|
+
return;
|
|
96
|
+
closed = true;
|
|
97
|
+
clearInterval(timer);
|
|
98
|
+
if (current === socket)
|
|
99
|
+
current = undefined;
|
|
100
|
+
notify();
|
|
101
|
+
if (!opened)
|
|
102
|
+
refuse(new Error('Remote chat socket closed before its first view'));
|
|
103
|
+
};
|
|
104
|
+
const timer = setInterval(() => {
|
|
105
|
+
if (Date.now() - heard > ping * 2.5) {
|
|
106
|
+
try {
|
|
107
|
+
socket.close();
|
|
108
|
+
}
|
|
109
|
+
catch { /* closing */ }
|
|
110
|
+
finish();
|
|
111
|
+
}
|
|
112
|
+
else if (socket.readyState === 1)
|
|
113
|
+
socket.send('ping');
|
|
114
|
+
}, ping);
|
|
115
|
+
const next = async () => { while (frames.length === 0 && !closed)
|
|
116
|
+
await new Promise(resolve => { wake = resolve; }); return frames.shift(); };
|
|
117
|
+
const cancel = () => { try {
|
|
118
|
+
socket.close(1000);
|
|
119
|
+
}
|
|
120
|
+
catch { /* closing */ } finish(); };
|
|
121
|
+
socket.onclose = finish;
|
|
122
|
+
socket.onerror = () => { try {
|
|
123
|
+
socket.close();
|
|
124
|
+
}
|
|
125
|
+
catch { /* closing */ } finish(); };
|
|
126
|
+
socket.onmessage = event => {
|
|
127
|
+
heard = Date.now();
|
|
128
|
+
if (typeof event.data !== 'string' || event.data === 'pong')
|
|
129
|
+
return;
|
|
130
|
+
let frame;
|
|
131
|
+
try {
|
|
132
|
+
frame = JSON.parse(event.data);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (frame.kind === 'reply') {
|
|
138
|
+
const pending = outbox.get(frame.id);
|
|
139
|
+
if (pending) {
|
|
140
|
+
outbox.delete(frame.id);
|
|
141
|
+
pending.resolve(new Response(JSON.stringify(frame.body), { status: frame.status, headers: { 'content-type': 'application/json' } }));
|
|
142
|
+
}
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (frame.kind === 'event') {
|
|
146
|
+
try {
|
|
147
|
+
options.onEvent?.(frame.event);
|
|
148
|
+
}
|
|
149
|
+
catch { /* a host listener never breaks the chat */ }
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!opened) {
|
|
153
|
+
if (frame.kind !== 'view') {
|
|
154
|
+
cancel();
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
opened = true;
|
|
158
|
+
current = socket;
|
|
159
|
+
// Unanswered calls go again, unchanged: a submit keeps its request ID, so the host runs it once.
|
|
160
|
+
for (const pending of outbox.values())
|
|
161
|
+
socket.send(pending.text);
|
|
162
|
+
ready({ first: frame.view, next, cancel });
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
frames.push(frame);
|
|
166
|
+
notify();
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
async function httpSource() {
|
|
54
171
|
const stopped = new AbortController();
|
|
55
172
|
const response = await send(new Request(url('watch'), { signal: stopped.signal }));
|
|
56
173
|
if (!response.ok || !response.body)
|
|
57
174
|
throw new WatchRefused(`Remote chat watch failed: ${await reason(response)}`, response.status);
|
|
58
175
|
const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
|
|
59
|
-
let pending = '', staleMs = 0
|
|
176
|
+
let pending = '', staleMs = 0;
|
|
60
177
|
// Once the server has announced its heartbeat interval, a read waiting 2.5 intervals means the stream is dead even if the
|
|
61
178
|
// socket still looks open (a proxy that dropped it silently).
|
|
62
179
|
async function read() {
|
|
@@ -65,7 +182,7 @@ export async function createRemoteChat(options) {
|
|
|
65
182
|
let timer;
|
|
66
183
|
try {
|
|
67
184
|
return await Promise.race([reader.read(), new Promise((_, reject) => {
|
|
68
|
-
timer = setTimeout(() => {
|
|
185
|
+
timer = setTimeout(() => { stopped.abort(); reject(new Error('Remote chat stream went silent')); }, staleMs);
|
|
69
186
|
})]);
|
|
70
187
|
}
|
|
71
188
|
finally {
|
|
@@ -97,11 +214,32 @@ export async function createRemoteChat(options) {
|
|
|
97
214
|
stopped.abort();
|
|
98
215
|
throw new Error('Remote chat watch ended before its first view');
|
|
99
216
|
}
|
|
100
|
-
|
|
217
|
+
return { first: first.view, next, cancel: () => stopped.abort() };
|
|
218
|
+
}
|
|
219
|
+
let socketWorked = false;
|
|
220
|
+
async function watch() {
|
|
221
|
+
let source;
|
|
222
|
+
if (mode === 'websocket') {
|
|
223
|
+
try {
|
|
224
|
+
source = await socketSource();
|
|
225
|
+
socketWorked = true;
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
if (socketWorked)
|
|
229
|
+
throw error;
|
|
230
|
+
fallBack();
|
|
231
|
+
source = await httpSource();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
else
|
|
235
|
+
source = await httpSource();
|
|
236
|
+
const { next } = source;
|
|
237
|
+
let lost = false;
|
|
238
|
+
let value = source.first, started = false, settle, ended;
|
|
101
239
|
const closed = new Promise(resolve => { settle = resolve; });
|
|
102
240
|
const finish = (end) => { if (!ended) {
|
|
103
241
|
ended = end;
|
|
104
|
-
|
|
242
|
+
source.cancel();
|
|
105
243
|
settle(end);
|
|
106
244
|
} };
|
|
107
245
|
const handle = {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -12,12 +12,16 @@
|
|
|
12
12
|
"dist/**/*.js",
|
|
13
13
|
"dist/**/*.d.ts",
|
|
14
14
|
"r/*.json",
|
|
15
|
+
"bin/*.mjs",
|
|
15
16
|
"README.md",
|
|
16
17
|
"LICENSE",
|
|
17
18
|
"THIRD_PARTY_NOTICES.md",
|
|
18
19
|
"styles"
|
|
19
20
|
],
|
|
20
21
|
"types": "./dist/index.d.ts",
|
|
22
|
+
"bin": {
|
|
23
|
+
"boring-ui-blocks": "./bin/boring-ui-blocks.mjs"
|
|
24
|
+
},
|
|
21
25
|
"exports": {
|
|
22
26
|
".": {
|
|
23
27
|
"types": "./dist/index.d.ts",
|
|
@@ -110,7 +114,7 @@
|
|
|
110
114
|
"./file-tree.css": "./styles/file-tree.css"
|
|
111
115
|
},
|
|
112
116
|
"peerDependencies": {
|
|
113
|
-
"@hachej/boring-files": "^0.4.
|
|
117
|
+
"@hachej/boring-files": "^0.4.1",
|
|
114
118
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
115
119
|
"react": "^19.0.0",
|
|
116
120
|
"react-dom": "^19.0.0",
|