pinfold 0.0.1 → 0.1.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/bin/pinfold.mjs +236 -0
- package/lib/commands.mjs +339 -0
- package/lib/detect.mjs +92 -0
- package/lib/mcp.mjs +124 -0
- package/lib/plan.mjs +170 -0
- package/package.json +38 -6
- package/registry.json +21 -0
- package/templates/component/css/__NAME__.css.tmpl +101 -0
- package/templates/component/react/__NAME__.test.tsx.tmpl +66 -0
- package/templates/component/react/__PASCAL__.tsx.tmpl +13 -0
- package/templates/component/react/__PASCAL__View.tsx.tmpl +114 -0
- package/templates/component/react/index.ts.tmpl +22 -0
- package/templates/component/react/lazy.ts.tmpl +11 -0
- package/templates/component/react/types.ts.tmpl +142 -0
- package/templates/component/react/use__PASCAL__.tsx.tmpl +181 -0
- package/LICENSE +0 -21
- package/README.md +0 -6
- package/bin/pinfold.js +0 -2
package/bin/pinfold.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Pinfold CLI (docs/CLI.md).
|
|
4
|
+
*
|
|
5
|
+
* npx pinfold init interactive setup: language, components, theme, entry, Tailwind
|
|
6
|
+
* npx pinfold add accordion | all components/ui/<name> plus its CSS import
|
|
7
|
+
* npx pinfold theme --primary emerald regenerate the palette (--format css|tailwind|dtcg, --check, --from <url>)
|
|
8
|
+
* npx pinfold doctor check React, CSS order, provider, 'use client', browsers, duplicates
|
|
9
|
+
* npx pinfold palette | list preview hues | show the registry
|
|
10
|
+
* npx pinfold new <name> scaffold a component (repo contributors)
|
|
11
|
+
*
|
|
12
|
+
* Every write is planned, printed and confirmed (or `--yes`); `--dry-run`
|
|
13
|
+
* prints only; existing files are merged into, never overwritten; running a
|
|
14
|
+
* command twice changes nothing the second time.
|
|
15
|
+
*/
|
|
16
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { dirname, join } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { HUES, createTheme } from '@pinfold/core/palette';
|
|
20
|
+
import { MODES, PACKS, RADII, add, doctor, init, theme } from '../lib/commands.mjs';
|
|
21
|
+
import { describe as describeProject, detect } from '../lib/detect.mjs';
|
|
22
|
+
|
|
23
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const REGISTRY = JSON.parse(readFileSync(join(HERE, '..', 'registry.json'), 'utf8'));
|
|
25
|
+
const TEMPLATES = join(HERE, '..', 'templates', 'component');
|
|
26
|
+
|
|
27
|
+
const tty = process.stdout.isTTY && !process.env.NO_COLOR;
|
|
28
|
+
const paint = (code) => (s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : String(s));
|
|
29
|
+
const color = { dim: paint(2), bold: paint(1), accent: paint('38;5;141'), ok: paint(32), warn: paint(33), err: paint(31) };
|
|
30
|
+
const swatch = (hex) => {
|
|
31
|
+
const [r, g, b] = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
32
|
+
return tty ? `\x1b[48;2;${r};${g};${b}m \x1b[0m` : '██';
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** `--name value`, `--name=value` and bare `--flag` into one object; the rest are positionals. */
|
|
36
|
+
function parse(argv) {
|
|
37
|
+
const flags = { cwd: process.cwd() };
|
|
38
|
+
const positional = [];
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (!a.startsWith('--')) {
|
|
42
|
+
positional.push(a);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const [key, inline] = a.slice(2).split('=');
|
|
46
|
+
if (inline !== undefined) flags[key] = inline;
|
|
47
|
+
else if (argv[i + 1] && !argv[i + 1].startsWith('--')) flags[key] = argv[++i];
|
|
48
|
+
else flags[key] = true;
|
|
49
|
+
}
|
|
50
|
+
return { flags, positional };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const out = {
|
|
54
|
+
color,
|
|
55
|
+
log: (s) => console.log(s),
|
|
56
|
+
error: (s) => console.error(` ${color.err('error')} ${s}`),
|
|
57
|
+
async confirm(message) {
|
|
58
|
+
if (!process.stdin.isTTY) return false;
|
|
59
|
+
const p = await import('@clack/prompts');
|
|
60
|
+
const ok = await p.confirm({ message });
|
|
61
|
+
return ok === true;
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Interactive `init`: every question has the detected or recommended default. */
|
|
66
|
+
async function askInit(flags) {
|
|
67
|
+
const p = await import('@clack/prompts');
|
|
68
|
+
const project = detect(flags.cwd);
|
|
69
|
+
p.intro(color.bold('Pinfold'));
|
|
70
|
+
p.log.info(`Detected: ${describeProject(project)}`);
|
|
71
|
+
const cancelled = (v) => {
|
|
72
|
+
if (p.isCancel(v)) {
|
|
73
|
+
p.cancel('Cancelled.');
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
77
|
+
};
|
|
78
|
+
const language = cancelled(
|
|
79
|
+
await p.select({ message: 'Language', initialValue: project.language, options: [{ value: 'ts', label: 'TypeScript' }, { value: 'js', label: 'JavaScript' }] }),
|
|
80
|
+
);
|
|
81
|
+
const components = cancelled(
|
|
82
|
+
await p.multiselect({
|
|
83
|
+
message: 'Components (a = all)',
|
|
84
|
+
initialValues: REGISTRY.components.map((c) => c.name),
|
|
85
|
+
options: REGISTRY.components.map((c) => ({ value: c.name, label: c.name, hint: c.description })),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
const primary = cancelled(await p.text({ message: `Brand hue (${Object.keys(HUES).join(', ')}, or 0-360)`, initialValue: String(flags.primary ?? 'violet') }));
|
|
89
|
+
const style = cancelled(await p.select({ message: 'Style pack', initialValue: 'default', options: PACKS.map((v) => ({ value: v, label: v })) }));
|
|
90
|
+
const radius = cancelled(await p.select({ message: 'Radius', initialValue: 'md', options: RADII.map((v) => ({ value: v, label: v })) }));
|
|
91
|
+
const mode = cancelled(await p.select({ message: 'Colour mode', initialValue: 'system', options: MODES.map((v) => ({ value: v, label: v })) }));
|
|
92
|
+
const entryFile = cancelled(await p.text({ message: 'Entry file', initialValue: project.entryFile ?? '', placeholder: 'e.g. src/main.tsx' }));
|
|
93
|
+
|
|
94
|
+
const preview = createTheme({ primary: Number.isFinite(Number(primary)) ? Number(primary) : primary });
|
|
95
|
+
const accent = preview.contrast.find((r) => r.role === 'primary' && r.step === 600 && r.against === 'light-surface');
|
|
96
|
+
p.log.info(`Brand ${[100, 300, 500, 700, 900].map((s) => swatch(preview.vars[`--pinfold-primary-${s}`])).join('')} text contrast ${accent.ratio}:1`);
|
|
97
|
+
p.outro('Review the changes below.');
|
|
98
|
+
return { language, components, entryFile: entryFile || null, theme: { primary, style, radius, mode } };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/* --------------------------------------------------- palette, list, new */
|
|
102
|
+
|
|
103
|
+
function palette() {
|
|
104
|
+
console.log(color.bold('\n Built-in hues\n'));
|
|
105
|
+
for (const name of Object.keys(HUES)) {
|
|
106
|
+
const t = createTheme({ primary: name });
|
|
107
|
+
const bar = [100, 200, 300, 400, 500, 600, 700, 800, 900].map((s) => swatch(t.vars[`--pinfold-primary-${s}`])).join('');
|
|
108
|
+
console.log(` ${name.padEnd(9)}${bar} ${color.dim(String(HUES[name]) + '°')}`);
|
|
109
|
+
}
|
|
110
|
+
console.log(color.dim('\n npx pinfold theme --primary <name> --accent <name> --radius lg\n'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function list() {
|
|
114
|
+
console.log(color.bold('\n Available\n'));
|
|
115
|
+
for (const x of REGISTRY.components) {
|
|
116
|
+
console.log(` ${color.accent(x.name.padEnd(14))}${x.description}`);
|
|
117
|
+
console.log(` ${''.padEnd(14)}${color.dim(`variants: ${x.variants.join(', ')} · hook: ${x.hook}()`)}`);
|
|
118
|
+
}
|
|
119
|
+
console.log(color.bold('\n Planned\n'));
|
|
120
|
+
console.log(' ' + color.dim(REGISTRY.planned.join(', ')) + '\n');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Scaffold a component inside the Pinfold monorepo (contributors only): the
|
|
125
|
+
* React adapter, the stylesheet and the subpath export, already shaped for
|
|
126
|
+
* the conformance suite.
|
|
127
|
+
*/
|
|
128
|
+
function scaffold(name, flags) {
|
|
129
|
+
if (!name) throw new Error('name required. Try `npx pinfold new tabs`.');
|
|
130
|
+
if (!/^[a-z][a-z0-9-]*$/.test(name)) throw new Error('name must be kebab-case, e.g. `date-picker`.');
|
|
131
|
+
const root = flags.root ?? '.';
|
|
132
|
+
const reactDir = join(root, 'packages', 'react', 'src', name);
|
|
133
|
+
const cssFile = join(root, 'packages', 'css', 'src', `${name}.css`);
|
|
134
|
+
const pkgFile = join(root, 'packages', 'react', 'package.json');
|
|
135
|
+
if (!existsSync(pkgFile)) throw new Error('run this from the Pinfold repo root (or pass --root).');
|
|
136
|
+
if (existsSync(reactDir) || existsSync(cssFile)) throw new Error(`${name} already exists.`);
|
|
137
|
+
|
|
138
|
+
const pascal = name.replace(/(^|-)([a-z])/g, (_, __, ch) => ch.toUpperCase());
|
|
139
|
+
const tokens = { NAME: name, PASCAL: pascal, CAMEL: pascal[0].toLowerCase() + pascal.slice(1) };
|
|
140
|
+
const apply = (text) => text.replace(/__(NAME|PASCAL|CAMEL)__/g, (_, key) => tokens[key]);
|
|
141
|
+
const copy = (from, to) => {
|
|
142
|
+
mkdirSync(to, { recursive: true });
|
|
143
|
+
for (const entry of readdirSync(from)) {
|
|
144
|
+
const src = join(from, entry);
|
|
145
|
+
const dest = join(to, apply(entry).replace(/\.tmpl$/, ''));
|
|
146
|
+
if (statSync(src).isDirectory()) copy(src, dest);
|
|
147
|
+
else writeFileSync(dest, apply(readFileSync(src, 'utf8')));
|
|
148
|
+
}
|
|
149
|
+
};
|
|
150
|
+
copy(join(TEMPLATES, 'react'), reactDir);
|
|
151
|
+
writeFileSync(cssFile, apply(readFileSync(join(TEMPLATES, 'css', '__NAME__.css.tmpl'), 'utf8')));
|
|
152
|
+
|
|
153
|
+
// The exports map drives the tsup entries and the webpack-4 stubs.
|
|
154
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
155
|
+
const sub = (p) => ({
|
|
156
|
+
import: { types: `./dist/${p}.d.ts`, default: `./dist/${p}.js` },
|
|
157
|
+
require: { types: `./dist/${p}.d.cts`, default: `./dist/${p}.cjs` },
|
|
158
|
+
});
|
|
159
|
+
const { ['./package.json']: pkgJson, ...rest } = pkg.exports;
|
|
160
|
+
pkg.exports = { ...rest, [`./${name}`]: sub(`${name}/index`), [`./${name}/lazy`]: sub(`${name}/lazy`), './package.json': pkgJson };
|
|
161
|
+
pkg.typesVersions['*'][name] = [`./dist/${name}/index.d.cts`];
|
|
162
|
+
pkg.typesVersions['*'][`${name}/lazy`] = [`./dist/${name}/lazy.d.cts`];
|
|
163
|
+
pkg.files = [...new Set([...pkg.files, name])];
|
|
164
|
+
writeFileSync(pkgFile, JSON.stringify(pkg, null, 2) + '\n');
|
|
165
|
+
|
|
166
|
+
console.log(`\n ${color.ok('created')} ${reactDir}\n ${color.ok('created')} ${cssFile}`);
|
|
167
|
+
console.log(` ${color.ok('wired')} @pinfold/react/${name} in packages/react/package.json\n`);
|
|
168
|
+
console.log(` Next: write docs/components/${name}.md, implement the WAI-ARIA pattern, add the`);
|
|
169
|
+
console.log(` client/define split and the tsup entries, register it, then ${color.accent('npm run verify')}\n`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function help() {
|
|
173
|
+
console.log(`
|
|
174
|
+
${color.bold('pinfold')} ${color.dim('— config-driven React UI')}
|
|
175
|
+
|
|
176
|
+
${color.accent('init')} [--yes] [--dry-run] [--no-install] [--primary violet] [--style glass] [--radius lg] [--mode dark]
|
|
177
|
+
${color.accent('add')} <component…|all> [--yes] [--dry-run] [--no-install]
|
|
178
|
+
${color.accent('theme')} [--primary emerald] [--accent blue] [--neutral 264] [--success|--warning|--danger|--info <hue>]
|
|
179
|
+
[--intensity 1.1] [--radius lg] [--style glass] [--out path] [--format css|tailwind|dtcg]
|
|
180
|
+
[--from <studio url>] [--check] [--dry-run]
|
|
181
|
+
${color.accent('doctor')}
|
|
182
|
+
${color.accent('palette')} preview every built-in hue
|
|
183
|
+
${color.accent('list')} show the registry
|
|
184
|
+
${color.accent('new')} <name> [--root path] scaffold a component (repo contributors)
|
|
185
|
+
|
|
186
|
+
Hues: ${color.dim(Object.keys(HUES).join(', '))}. Any 0-360 number works too.
|
|
187
|
+
`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/* ------------------------------------------------------------- dispatch */
|
|
191
|
+
|
|
192
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
193
|
+
const { flags, positional } = parse(rest);
|
|
194
|
+
try {
|
|
195
|
+
switch (command) {
|
|
196
|
+
case 'init': {
|
|
197
|
+
const interactive = process.stdin.isTTY && !flags.yes && !flags['dry-run'];
|
|
198
|
+
await init(flags, out, REGISTRY, interactive ? await askInit(flags) : {});
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
case 'add':
|
|
202
|
+
await add(positional, flags, out, REGISTRY);
|
|
203
|
+
break;
|
|
204
|
+
case 'theme':
|
|
205
|
+
process.exitCode = await theme(flags, out);
|
|
206
|
+
break;
|
|
207
|
+
case 'doctor':
|
|
208
|
+
process.exitCode = doctor(flags, out);
|
|
209
|
+
break;
|
|
210
|
+
case 'palette':
|
|
211
|
+
palette();
|
|
212
|
+
break;
|
|
213
|
+
case 'list':
|
|
214
|
+
list();
|
|
215
|
+
break;
|
|
216
|
+
case 'new':
|
|
217
|
+
scaffold(positional[0], flags);
|
|
218
|
+
break;
|
|
219
|
+
case 'mcp':
|
|
220
|
+
await (await import('../lib/mcp.mjs')).serve();
|
|
221
|
+
break;
|
|
222
|
+
case undefined:
|
|
223
|
+
case 'help':
|
|
224
|
+
case '--help':
|
|
225
|
+
case '-h':
|
|
226
|
+
help();
|
|
227
|
+
break;
|
|
228
|
+
default:
|
|
229
|
+
out.error(`unknown command \`${command}\``);
|
|
230
|
+
help();
|
|
231
|
+
process.exitCode = 1;
|
|
232
|
+
}
|
|
233
|
+
} catch (error) {
|
|
234
|
+
out.error(error.message);
|
|
235
|
+
process.exitCode = 1;
|
|
236
|
+
}
|
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* init, add, theme and doctor (docs/CLI.md). Each builds a Plan from what
|
|
3
|
+
* `detect()` found, prints it, and applies it only with consent (`--yes` or a
|
|
4
|
+
* prompt). Nothing here talks to the network except the package install,
|
|
5
|
+
* through the project's own package manager.
|
|
6
|
+
*/
|
|
7
|
+
import { execSync } from 'node:child_process';
|
|
8
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
9
|
+
import { dirname, join, posix, relative } from 'node:path';
|
|
10
|
+
import { createTheme, toDTCG } from '@pinfold/core/palette';
|
|
11
|
+
import { describe as describeProject, detect } from './detect.mjs';
|
|
12
|
+
import { Plan, addCssImport, addImports, addNamedImport, addTailwindPreset } from './plan.mjs';
|
|
13
|
+
|
|
14
|
+
export const RADII = ['none', 'sm', 'md', 'lg', 'full'];
|
|
15
|
+
export const PACKS = ['default', 'glass', 'bauhaus', 'brutalist', 'noise', 'soft'];
|
|
16
|
+
export const MODES = ['system', 'light', 'dark'];
|
|
17
|
+
const ROLES = ['primary', 'accent', 'neutral', 'success', 'warning', 'danger', 'info'];
|
|
18
|
+
const THEME_FILE = 'pinfold-theme.css';
|
|
19
|
+
const THEME_MARK = '/* pinfold-theme ';
|
|
20
|
+
|
|
21
|
+
/* ------------------------------------------------------------- helpers */
|
|
22
|
+
|
|
23
|
+
const toPosix = (p) => p.split('\\').join('/');
|
|
24
|
+
const entryDir = (p) => (p.entryFile ? posix.dirname(toPosix(p.entryFile)) : p.componentsDir.startsWith('src') ? 'src' : '.');
|
|
25
|
+
const themePath = (p) => posix.join(entryDir(p), THEME_FILE);
|
|
26
|
+
const ext = (p) => (p.language === 'ts' ? 'tsx' : 'jsx');
|
|
27
|
+
|
|
28
|
+
/** Relative import specifier from one project file to another. */
|
|
29
|
+
function specifier(fromFile, toFile) {
|
|
30
|
+
const rel = toPosix(relative(dirname(fromFile), toFile));
|
|
31
|
+
return rel.startsWith('.') ? rel : './' + rel;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The palette saved in the theme file's first line, so `theme` can change one flag and keep the rest. */
|
|
35
|
+
export function readPalette(source) {
|
|
36
|
+
if (!source?.startsWith(THEME_MARK)) return null;
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(source.slice(THEME_MARK.length, source.indexOf(' */')));
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function themeCss(palette) {
|
|
45
|
+
return `${THEME_MARK}${JSON.stringify(palette)} */\n${createTheme(palette).css}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Palette flags (and a shared studio URL via --from) layered over a base palette. */
|
|
49
|
+
export function paletteFrom(flags, base = {}) {
|
|
50
|
+
const fromUrl = flags.from ? Object.fromEntries(new URL(flags.from).searchParams) : {};
|
|
51
|
+
const pick = (k) => flags[k] ?? fromUrl[k];
|
|
52
|
+
const next = { ...base };
|
|
53
|
+
for (const role of ROLES) {
|
|
54
|
+
const v = pick(role);
|
|
55
|
+
if (v !== undefined) next[role] = Number.isFinite(Number(v)) ? Number(v) : v;
|
|
56
|
+
}
|
|
57
|
+
if (pick('intensity') !== undefined) next.intensity = Number(pick('intensity'));
|
|
58
|
+
if (RADII.includes(pick('radius'))) next.radius = pick('radius');
|
|
59
|
+
next.primary ??= 'violet';
|
|
60
|
+
next.radius ??= 'md';
|
|
61
|
+
return { palette: next, style: pick('style'), mode: pick('mode') };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Packages missing from package.json, installed with the project's own package manager. */
|
|
65
|
+
function ensurePackages(project, flags, out) {
|
|
66
|
+
const missing = ['@pinfold/react', '@pinfold/css'].filter((n) => !(n in project.deps));
|
|
67
|
+
if (!missing.length) return;
|
|
68
|
+
const pm = project.packageManager;
|
|
69
|
+
const cmd = pm === 'npm' ? `npm install ${missing.join(' ')}` : `${pm} add ${missing.join(' ')}`;
|
|
70
|
+
if (flags['dry-run'] || flags['no-install']) {
|
|
71
|
+
out.log(` run: ${cmd}`);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
out.log(` $ ${cmd}`);
|
|
75
|
+
execSync(cmd, { cwd: project.cwd, stdio: 'inherit' });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Print the plan and apply it with consent. Returns false when nothing was written. */
|
|
79
|
+
async function commit(plan, flags, out) {
|
|
80
|
+
if (plan.empty) {
|
|
81
|
+
if (plan.notes.length) out.log(plan.describe(out.color));
|
|
82
|
+
out.log(out.color.dim(' Nothing to change.'));
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
out.log(plan.describe(out.color));
|
|
86
|
+
if (flags['dry-run']) {
|
|
87
|
+
out.log(out.color.dim('\n Dry run: nothing written.'));
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (!flags.yes && !(await out.confirm(`Apply ${plan.changes.size} change(s)?`))) {
|
|
91
|
+
out.log(' Cancelled.');
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
plan.apply();
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/* ------------------------------------------------------------ registry */
|
|
99
|
+
|
|
100
|
+
export function componentFile(name, language) {
|
|
101
|
+
const pascal = name.replace(/(^|-)([a-z])/g, (_, __, ch) => ch.toUpperCase());
|
|
102
|
+
const head = [
|
|
103
|
+
'/**',
|
|
104
|
+
` * Pinfold ${name}. This file is yours: import from here across the app,`,
|
|
105
|
+
' * and change shared defaults in one place.',
|
|
106
|
+
` * Docs: https://pinfold.indranildev.in/docs/${name}`,
|
|
107
|
+
' */',
|
|
108
|
+
];
|
|
109
|
+
const values = `export { use${pascal}, define${pascal}, ${pascal} } from '@pinfold/react/${name}';`;
|
|
110
|
+
if (language === 'ts') {
|
|
111
|
+
return [...head, values, `export type { ${pascal}Config, ${pascal}ItemConfig, ${pascal}Api } from '@pinfold/react/${name}';`, ''].join('\n');
|
|
112
|
+
}
|
|
113
|
+
return [...head, `/** @typedef {import('@pinfold/react/${name}').${pascal}Config} ${pascal}Config */`, values, ''].join('\n');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Plan one component: its owned file and its stylesheet import. */
|
|
117
|
+
function planComponent(plan, project, name) {
|
|
118
|
+
plan.create(posix.join(project.componentsDir, `${name}.${ext(project)}`), componentFile(name, project.language));
|
|
119
|
+
if (project.entryFile) plan.update(toPosix(project.entryFile), (src) => addImports(src, ['@pinfold/css/core.css', `@pinfold/css/${name}.css`]));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ------------------------------------------------------------- commands */
|
|
123
|
+
|
|
124
|
+
export async function add(names, flags, out, registry) {
|
|
125
|
+
const project = detect(flags.cwd);
|
|
126
|
+
const wanted = names.includes('all') ? registry.components.map((c) => c.name) : names;
|
|
127
|
+
if (!wanted.length) throw new Error('nothing to add. Try `npx pinfold add accordion`.');
|
|
128
|
+
const plan = new Plan(project.cwd);
|
|
129
|
+
for (const name of wanted) {
|
|
130
|
+
if (!registry.components.some((c) => c.name === name)) {
|
|
131
|
+
plan.note(`skip ${name}: not in the registry yet`);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
planComponent(plan, project, name);
|
|
135
|
+
}
|
|
136
|
+
if (!project.entryFile) plan.note(`no entry file found: import '@pinfold/css/core.css' and each component's CSS yourself`);
|
|
137
|
+
ensurePackages(project, flags, out);
|
|
138
|
+
await commit(plan, flags, out);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function init(flags, out, registry, answers = {}) {
|
|
142
|
+
const project = detect(flags.cwd);
|
|
143
|
+
if (answers.language) project.language = answers.language;
|
|
144
|
+
if (answers.entryFile) project.entryFile = answers.entryFile;
|
|
145
|
+
out.log(` ${out.color.dim('Detected:')} ${describeProject(project)}`);
|
|
146
|
+
|
|
147
|
+
const existingTheme = readPalette(new Plan(project.cwd).read(themePath(project)));
|
|
148
|
+
const { palette, style, mode } = paletteFrom({ ...flags, ...answers.theme }, existingTheme ?? {});
|
|
149
|
+
const stylePack = PACKS.includes(style) ? style : 'default';
|
|
150
|
+
const theme = MODES.includes(mode) ? mode : 'system';
|
|
151
|
+
const components = answers.components ?? registry.components.map((c) => c.name);
|
|
152
|
+
|
|
153
|
+
const plan = new Plan(project.cwd);
|
|
154
|
+
|
|
155
|
+
// 1. The global config, written once and then owned by the developer.
|
|
156
|
+
const configPath = project.configFile ?? `pinfold.config.${project.language === 'ts' ? 'ts' : 'js'}`;
|
|
157
|
+
const config = { theme, radius: palette.radius, ...(stylePack !== 'default' ? { stylePack } : {}) };
|
|
158
|
+
plan.create(
|
|
159
|
+
configPath,
|
|
160
|
+
[
|
|
161
|
+
"import { defineConfig } from '@pinfold/react';",
|
|
162
|
+
'',
|
|
163
|
+
'// Global Pinfold config. Precedence: component config, then `defaults`, then this.',
|
|
164
|
+
'export default defineConfig({',
|
|
165
|
+
...Object.entries(config).map(([k, v]) => ` ${k}: '${v}',`),
|
|
166
|
+
' defaults: {},',
|
|
167
|
+
'});',
|
|
168
|
+
'',
|
|
169
|
+
].join('\n'),
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
// 2. Theme CSS (Pinfold owns and regenerates it), imported after core and component CSS.
|
|
173
|
+
const themeFile = themePath(project);
|
|
174
|
+
plan.generate(themeFile, themeCss(palette));
|
|
175
|
+
|
|
176
|
+
// 3. Components and their stylesheets.
|
|
177
|
+
for (const name of components) planComponent(plan, project, name);
|
|
178
|
+
|
|
179
|
+
if (project.entryFile) {
|
|
180
|
+
const entry = toPosix(project.entryFile);
|
|
181
|
+
const css = ['@pinfold/css/core.css', ...(stylePack !== 'default' ? ['@pinfold/css/packs.css'] : []), specifier(entry, themeFile)];
|
|
182
|
+
plan.update(entry, (src) => addImports(src, css));
|
|
183
|
+
// 4. Provider, only when the config differs from the library defaults.
|
|
184
|
+
if (theme !== 'system' || palette.radius !== 'md' || stylePack !== 'default') wrapProvider(plan, project, entry, configPath);
|
|
185
|
+
} else {
|
|
186
|
+
plan.note(`no entry file found: import '@pinfold/css/core.css' and './${THEME_FILE}' in your app entry`);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// 5. Tailwind: merge, never replace.
|
|
190
|
+
if (project.tailwind === 4 && project.globalCss) plan.update(toPosix(project.globalCss), (css) => addCssImport(css, '@pinfold/css/tailwind.css'));
|
|
191
|
+
if (project.tailwind === 3 && project.tailwindConfig) plan.update(project.tailwindConfig, addTailwindPreset);
|
|
192
|
+
|
|
193
|
+
ensurePackages(project, flags, out);
|
|
194
|
+
if (await commit(plan, flags, out)) out.log(`\n Next: use it, e.g. ${out.color.accent(`import { useAccordion } from '@/components/ui/accordion'`)}\n`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function wrapProvider(plan, project, entry, configPath) {
|
|
198
|
+
const targets = {
|
|
199
|
+
app: /\{children\}/,
|
|
200
|
+
pages: /<Component \{\.\.\.pageProps\} \/>/,
|
|
201
|
+
vite: /<App \/>/,
|
|
202
|
+
react: /<App \/>/,
|
|
203
|
+
};
|
|
204
|
+
const key = project.framework === 'next' ? project.router : project.framework;
|
|
205
|
+
const target = targets[key];
|
|
206
|
+
plan.update(entry, (src) => {
|
|
207
|
+
if (src.includes('PinfoldProvider')) return src;
|
|
208
|
+
if (!target || !target.test(src)) {
|
|
209
|
+
plan.note(`wrap your app in <PinfoldProvider config={pinfoldConfig}> (could not find where in ${entry})`);
|
|
210
|
+
return src;
|
|
211
|
+
}
|
|
212
|
+
let out = src.replace(target, (m) => `<PinfoldProvider config={pinfoldConfig}>${m}</PinfoldProvider>`);
|
|
213
|
+
out = addNamedImport(out, 'PinfoldProvider', '@pinfold/react');
|
|
214
|
+
return addNamedImport(out, 'pinfoldConfig', specifier(entry, configPath).replace(/\.(ts|js|mjs)$/, ''), true);
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function theme(flags, out) {
|
|
219
|
+
const project = detect(flags.cwd);
|
|
220
|
+
const plan = new Plan(project.cwd);
|
|
221
|
+
const file = flags.out ?? themePath(project);
|
|
222
|
+
const { palette, style } = paletteFrom(flags, readPalette(plan.read(file)) ?? {});
|
|
223
|
+
const built = createTheme(palette);
|
|
224
|
+
const format = flags.format ?? 'css';
|
|
225
|
+
|
|
226
|
+
if (format === 'dtcg') plan.generate(file.replace(/\.css$/, '.tokens.json'), JSON.stringify(toDTCG(built), null, 2) + '\n');
|
|
227
|
+
else if (format === 'tailwind') plan.generate(file, `@import "@pinfold/css/tailwind.css";\n${themeCss(palette)}`);
|
|
228
|
+
else plan.generate(file, themeCss(palette));
|
|
229
|
+
|
|
230
|
+
if (PACKS.includes(style) && project.configFile) {
|
|
231
|
+
plan.update(project.configFile, (src) =>
|
|
232
|
+
/stylePack:/.test(src)
|
|
233
|
+
? src.replace(/stylePack:\s*['"][a-z]+['"]/, `stylePack: '${style}'`)
|
|
234
|
+
: src.replace(/defineConfig\(\{/, `defineConfig({\n stylePack: '${style}',`),
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
await commit(plan, { ...flags, yes: flags.yes ?? true }, out);
|
|
239
|
+
|
|
240
|
+
if (flags.check) {
|
|
241
|
+
// The accent is text in both modes: step 600 on light surfaces, 400 on dark ones.
|
|
242
|
+
const failing = built.contrast.filter(
|
|
243
|
+
(r) =>
|
|
244
|
+
(r.role === 'primary' || r.role === 'accent') &&
|
|
245
|
+
((r.step === 600 && r.against === 'light-surface') || (r.step === 400 && r.against === 'dark-surface')) &&
|
|
246
|
+
!r.passes.text,
|
|
247
|
+
);
|
|
248
|
+
for (const r of built.contrast.filter((r) => r.role === 'primary' || r.role === 'accent')) {
|
|
249
|
+
out.log(` ${r.role.padEnd(8)} ${String(r.step).padEnd(4)} ${r.against.padEnd(14)} ${String(r.ratio).padStart(5)}:1`);
|
|
250
|
+
}
|
|
251
|
+
if (failing.length) {
|
|
252
|
+
out.error(`contrast: ${failing.map((r) => `${r.role}-${r.step} on ${r.against} (${r.ratio}:1)`).join(', ')} below 4.5:1`);
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
out.log(out.color.ok(' contrast: accent text passes WCAG AA in light and dark mode'));
|
|
256
|
+
}
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* --------------------------------------------------------------- doctor */
|
|
261
|
+
|
|
262
|
+
export function doctor(flags, out) {
|
|
263
|
+
const project = detect(flags.cwd);
|
|
264
|
+
const problems = [];
|
|
265
|
+
const warn = [];
|
|
266
|
+
const read = (f) => (f && existsSync(join(project.cwd, f)) ? readFileSync(join(project.cwd, f), 'utf8') : '');
|
|
267
|
+
|
|
268
|
+
// React 18+
|
|
269
|
+
const reactPkg = join(project.cwd, 'node_modules/react/package.json');
|
|
270
|
+
const reactVersion = existsSync(reactPkg) ? JSON.parse(readFileSync(reactPkg, 'utf8')).version : String(project.deps.react ?? '').replace(/^[^\d]*/, '');
|
|
271
|
+
if (reactVersion && Number(reactVersion.split('.')[0]) < 18) {
|
|
272
|
+
problems.push(`React ${reactVersion} found; Pinfold needs React 18 or newer. Upgrade guide: https://react.dev/blog/2022/03/08/react-18-upgrade-guide`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// CSS imports: core before components; theme present.
|
|
276
|
+
const entry = read(project.entryFile);
|
|
277
|
+
if (project.entryFile) {
|
|
278
|
+
const at = (spec) => entry.indexOf(spec);
|
|
279
|
+
if (at('@pinfold/css/core.css') === -1) problems.push(`${project.entryFile}: missing import '@pinfold/css/core.css'. Fix: npx pinfold init`);
|
|
280
|
+
for (const m of entry.matchAll(/@pinfold\/css\/([a-z-]+)\.css/g)) {
|
|
281
|
+
if (m[1] !== 'core' && at('@pinfold/css/core.css') > m.index) problems.push(`${project.entryFile}: import '@pinfold/css/core.css' before '@pinfold/css/${m[1]}.css'.`);
|
|
282
|
+
}
|
|
283
|
+
if (!entry.includes(THEME_FILE)) warn.push(`${project.entryFile}: no ${THEME_FILE} import; the default palette applies. Fix: npx pinfold theme`);
|
|
284
|
+
} else {
|
|
285
|
+
warn.push('no entry file detected; check your CSS imports by hand.');
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Provider when the config needs one.
|
|
289
|
+
const config = read(project.configFile);
|
|
290
|
+
const needsProvider = /stylePack:|theme:\s*['"](light|dark)['"]|radius:\s*['"](none|sm|lg|full)['"]|defaults:\s*\{\s*\w/.test(config);
|
|
291
|
+
if (needsProvider && !entry.includes('PinfoldProvider')) problems.push(`${project.configFile} sets options, but no <PinfoldProvider config={…}> wraps the app in ${project.entryFile}.`);
|
|
292
|
+
|
|
293
|
+
// 'use client' in Next.js App Router files that call hooks.
|
|
294
|
+
if (project.framework === 'next' && project.router === 'app') {
|
|
295
|
+
for (const file of walk(project.cwd, existsSync(join(project.cwd, 'src/app')) ? 'src/app' : 'app')) {
|
|
296
|
+
const src = read(file);
|
|
297
|
+
if (/\buse[A-Z]\w*\(/.test(src) && /@pinfold\/react|components\/ui\//.test(src) && !/^\s*['"]use client['"]/.test(src)) {
|
|
298
|
+
problems.push(`${file}: calls a Pinfold hook without 'use client'. Add the directive, or use define<Name>() in a Server Component.`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Browser targets vs Baseline 2023.
|
|
304
|
+
const browsers = [].concat(project.pkg.browserslist ?? [], read('.browserslistrc').split('\n')).join(',');
|
|
305
|
+
if (/\bie\b|op_mini|chrome\s*[<>]=?\s*(\d{1,2}|10\d|110)\b/i.test(browsers)) {
|
|
306
|
+
warn.push('browserslist targets browsers older than Baseline 2023 (Chrome/Edge 111, Firefox 113, Safari 16.4); Pinfold CSS needs @layer, color-mix() and container queries.');
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Duplicate @pinfold installs.
|
|
310
|
+
const versions = new Set(findInstalls(join(project.cwd, 'node_modules')).map((p) => JSON.parse(readFileSync(p, 'utf8')).version));
|
|
311
|
+
if (versions.size > 1) problems.push(`@pinfold/core is installed more than once (${[...versions].join(', ')}); dedupe so every component shares one provider.`);
|
|
312
|
+
|
|
313
|
+
for (const w of warn) out.log(` ${out.color.warn('warn')} ${w}`);
|
|
314
|
+
for (const p of problems) out.log(` ${out.color.err('error')} ${p}`);
|
|
315
|
+
if (!problems.length) out.log(out.color.ok(` doctor: no problems${warn.length ? ` (${warn.length} warning${warn.length > 1 ? 's' : ''})` : ''}`));
|
|
316
|
+
return problems.length ? 1 : 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function walk(root, dir) {
|
|
320
|
+
const abs = join(root, dir);
|
|
321
|
+
if (!existsSync(abs)) return [];
|
|
322
|
+
return readdirSync(abs).flatMap((name) => {
|
|
323
|
+
const rel = posix.join(dir, name);
|
|
324
|
+
if (statSync(join(root, rel)).isDirectory()) return walk(root, rel);
|
|
325
|
+
return /\.(tsx|jsx|ts|js)$/.test(name) ? [rel] : [];
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function findInstalls(nodeModules, depth = 0, out = []) {
|
|
330
|
+
const core = join(nodeModules, '@pinfold/core/package.json');
|
|
331
|
+
if (existsSync(core)) out.push(core);
|
|
332
|
+
if (depth > 3 || !existsSync(nodeModules)) return out;
|
|
333
|
+
for (const scope of ['@pinfold']) {
|
|
334
|
+
const dir = join(nodeModules, scope);
|
|
335
|
+
if (!existsSync(dir)) continue;
|
|
336
|
+
for (const pkg of readdirSync(dir)) findInstalls(join(dir, pkg, 'node_modules'), depth + 1, out);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}
|
package/lib/detect.mjs
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read a React project without touching it: framework, router, language,
|
|
3
|
+
* Tailwind major, package manager, entry file, global CSS and where
|
|
4
|
+
* components live. Every command starts here.
|
|
5
|
+
*/
|
|
6
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
|
|
9
|
+
const first = (cwd, candidates) => candidates.find((f) => existsSync(join(cwd, f))) ?? null;
|
|
10
|
+
const exts = (base, list = ['tsx', 'jsx', 'ts', 'js']) => list.map((e) => `${base}.${e}`);
|
|
11
|
+
const major = (range) => {
|
|
12
|
+
const m = String(range ?? '').match(/(\d+)/);
|
|
13
|
+
return m ? Number(m[1]) : null;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function readPackageJson(cwd) {
|
|
17
|
+
const file = join(cwd, 'package.json');
|
|
18
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detect(cwd = process.cwd()) {
|
|
22
|
+
const pkg = readPackageJson(cwd) ?? {};
|
|
23
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
24
|
+
const has = (name) => name in deps;
|
|
25
|
+
|
|
26
|
+
let framework = 'react';
|
|
27
|
+
let router = null;
|
|
28
|
+
if (has('next')) {
|
|
29
|
+
framework = 'next';
|
|
30
|
+
router = first(cwd, [...exts('app/layout'), ...exts('src/app/layout')]) ? 'app' : 'pages';
|
|
31
|
+
} else if (has('@react-router/dev') || has('@remix-run/react')) framework = 'react-router';
|
|
32
|
+
else if (has('gatsby')) framework = 'gatsby';
|
|
33
|
+
else if (has('astro')) framework = 'astro';
|
|
34
|
+
else if (has('vite')) framework = 'vite';
|
|
35
|
+
|
|
36
|
+
const language = existsSync(join(cwd, 'tsconfig.json')) ? 'ts' : 'js';
|
|
37
|
+
|
|
38
|
+
const twMajor = major(deps.tailwindcss);
|
|
39
|
+
const tailwind = twMajor == null ? null : twMajor >= 4 ? 4 : 3;
|
|
40
|
+
|
|
41
|
+
const packageManager = existsSync(join(cwd, 'pnpm-lock.yaml'))
|
|
42
|
+
? 'pnpm'
|
|
43
|
+
: existsSync(join(cwd, 'yarn.lock'))
|
|
44
|
+
? 'yarn'
|
|
45
|
+
: existsSync(join(cwd, 'bun.lockb')) || existsSync(join(cwd, 'bun.lock'))
|
|
46
|
+
? 'bun'
|
|
47
|
+
: 'npm';
|
|
48
|
+
|
|
49
|
+
const entry = {
|
|
50
|
+
next: router === 'app' ? [...exts('app/layout'), ...exts('src/app/layout')] : [...exts('pages/_app'), ...exts('src/pages/_app')],
|
|
51
|
+
vite: [...exts('src/main'), ...exts('src/index')],
|
|
52
|
+
'react-router': exts('app/root'),
|
|
53
|
+
gatsby: exts('gatsby-browser'),
|
|
54
|
+
astro: [],
|
|
55
|
+
react: [...exts('src/index'), ...exts('src/main')],
|
|
56
|
+
}[framework];
|
|
57
|
+
|
|
58
|
+
const globalCss = first(cwd, [
|
|
59
|
+
'app/globals.css',
|
|
60
|
+
'src/app/globals.css',
|
|
61
|
+
'styles/globals.css',
|
|
62
|
+
'src/styles/globals.css',
|
|
63
|
+
'src/index.css',
|
|
64
|
+
'app/app.css',
|
|
65
|
+
'src/global.css',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
const tailwindConfig = first(cwd, ['tailwind.config.js', 'tailwind.config.cjs', 'tailwind.config.mjs', 'tailwind.config.ts']);
|
|
69
|
+
const srcDir = existsSync(join(cwd, 'src')) ? 'src' : '.';
|
|
70
|
+
const entryFile = first(cwd, entry);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
cwd,
|
|
74
|
+
pkg,
|
|
75
|
+
deps,
|
|
76
|
+
framework,
|
|
77
|
+
router,
|
|
78
|
+
language,
|
|
79
|
+
tailwind,
|
|
80
|
+
tailwindConfig,
|
|
81
|
+
packageManager,
|
|
82
|
+
entryFile,
|
|
83
|
+
globalCss,
|
|
84
|
+
componentsDir: srcDir === 'src' ? 'src/components/ui' : 'components/ui',
|
|
85
|
+
configFile: first(cwd, ['pinfold.config.ts', 'pinfold.config.js', 'pinfold.config.mjs']),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function describe(p) {
|
|
90
|
+
const fw = p.framework === 'next' ? `Next.js (${p.router === 'app' ? 'App Router' : 'Pages Router'})` : p.framework;
|
|
91
|
+
return [fw, p.language === 'ts' ? 'TypeScript' : 'JavaScript', p.tailwind ? `Tailwind v${p.tailwind}` : 'no Tailwind', p.packageManager].join(' · ');
|
|
92
|
+
}
|