synthesisui 0.16.16 → 0.16.17
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/dist/commands/component.js +24 -2
- package/dist/component-codegen.js +71 -16
- package/dist/doctor/scan.js +29 -6
- package/dist/doctor/tokens.js +27 -3
- package/package.json +1 -1
|
@@ -1,10 +1,32 @@
|
|
|
1
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { generateComponentFiles } from "../component-codegen.js";
|
|
4
4
|
import { readProjectConfig, resolveRegistry } from "../config.js";
|
|
5
5
|
import { hasInteractiveTemplate, interactiveTemplate, } from "../interactive-templates.js";
|
|
6
6
|
import { body, section, snippet } from "../output.js";
|
|
7
7
|
import { fetchComponent, RegistryError } from "../registry.js";
|
|
8
|
+
/**
|
|
9
|
+
* The consumer's React major, or null when we cannot tell.
|
|
10
|
+
*
|
|
11
|
+
* It decides whether the generated components can take a `ref`: from 19 that is
|
|
12
|
+
* an ordinary prop, before it a function component needs `forwardRef`. Guessing
|
|
13
|
+
* high on an older project would emit a type that accepts a ref React then
|
|
14
|
+
* silently drops, so anything unreadable falls back to the ref-less type.
|
|
15
|
+
*/
|
|
16
|
+
async function reactMajorOf(root) {
|
|
17
|
+
const raw = await readFile(join(root, "package.json"), "utf8").catch(() => "");
|
|
18
|
+
if (!raw)
|
|
19
|
+
return null;
|
|
20
|
+
try {
|
|
21
|
+
const pkg = JSON.parse(raw);
|
|
22
|
+
const spec = pkg.dependencies?.react ?? pkg.devDependencies?.react;
|
|
23
|
+
const major = /(\d+)/.exec(spec ?? "")?.[1];
|
|
24
|
+
return major ? Number(major) : null;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
8
30
|
/** Slugs/names are kebab-case by contract; reject anything else before it ever
|
|
9
31
|
* reaches a filesystem path (defense-in-depth against `../` traversal). */
|
|
10
32
|
const SAFE_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
@@ -59,7 +81,7 @@ export async function component(slug, name, opts) {
|
|
|
59
81
|
filenames = [`${res.name}.tsx`, `${res.name}.css`, "index.ts"];
|
|
60
82
|
}
|
|
61
83
|
else {
|
|
62
|
-
const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles);
|
|
84
|
+
const files = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, config.styles, await reactMajorOf(root));
|
|
63
85
|
for (const file of files) {
|
|
64
86
|
await writeFile(join(compDir, file.filename), file.code, "utf8");
|
|
65
87
|
}
|
|
@@ -8,6 +8,28 @@ const camel = (name) => {
|
|
|
8
8
|
const p = pascal(name);
|
|
9
9
|
return p[0].toLowerCase() + p.slice(1);
|
|
10
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* A PART THAT STYLES FOCUS IS A CONTROL, AND EVERY ONE OF THEM SHIPPED AS A DIV.
|
|
13
|
+
*
|
|
14
|
+
* Found by an agent building a carousel (27/07). `PaginationItem` came out as a
|
|
15
|
+
* `<div>` carrying `cursor-pointer`, `hover:text-foreground`,
|
|
16
|
+
* `focus-visible:[outline:2px]` and `disabled:[opacity:0.4]` - every affordance
|
|
17
|
+
* of a button on an element that is not focusable, is not announced as a
|
|
18
|
+
* control, and where `disabled:` can never match. It worked around us by
|
|
19
|
+
* putting the class on a real `<button>` instead of using the component.
|
|
20
|
+
*
|
|
21
|
+
* The recipe already knew. Nobody writes a focus ring on a div by accident, so
|
|
22
|
+
* `focus`/`focusVisible`/`disabled` in a part's states IS the declaration that
|
|
23
|
+
* it is operable. `hover` deliberately does not count: a card that lifts under
|
|
24
|
+
* the cursor is still a card.
|
|
25
|
+
*
|
|
26
|
+
* Conservative by construction. Across the shipped catalogue this promotes
|
|
27
|
+
* three parts of fifty - stepper.button, tabs.trigger, pagination.item - and
|
|
28
|
+
* all three are buttons that were wearing the wrong tag.
|
|
29
|
+
*/
|
|
30
|
+
export function partIsInteractive(part) {
|
|
31
|
+
return Object.keys(part.states ?? {}).some((s) => /^(focus|focusVisible|focus-visible|disabled)$/i.test(s));
|
|
32
|
+
}
|
|
11
33
|
/** Intrinsic element + extra attrs per component, chosen like the platform
|
|
12
34
|
* renderer does (name first, then preview.kind). Fallback: div + children. */
|
|
13
35
|
function elementFor(name, recipe) {
|
|
@@ -82,13 +104,36 @@ function axesOf(variants) {
|
|
|
82
104
|
}
|
|
83
105
|
return axes;
|
|
84
106
|
}
|
|
85
|
-
|
|
107
|
+
/**
|
|
108
|
+
* THE COMPONENTS COULD NOT TAKE A REF, AND SOME UI CANNOT BE BUILT WITHOUT ONE.
|
|
109
|
+
*
|
|
110
|
+
* `ComponentPropsWithoutRef` is the correct type under React 18, where a
|
|
111
|
+
* function component needs `forwardRef` to receive one. Under React 19 `ref` is
|
|
112
|
+
* an ordinary prop, so the same code works and the type is simply lying about
|
|
113
|
+
* what the component accepts.
|
|
114
|
+
*
|
|
115
|
+
* It costs real things. An agent building a dense screen (28/07) had to address
|
|
116
|
+
* rows by DOM id and reach for `document.getElementById`, because roving focus
|
|
117
|
+
* needs a ref. And `indeterminate` on a checkbox is a DOM property with no HTML
|
|
118
|
+
* attribute: a partial select-all is impossible without one.
|
|
119
|
+
*
|
|
120
|
+
* Read from the consumer's own package.json, never assumed. Unknown or older
|
|
121
|
+
* keeps today's behaviour, because emitting a ref-taking type onto React 18
|
|
122
|
+
* trades a missing feature for a silent one - the ref is quietly undefined and
|
|
123
|
+
* only a dev-mode warning says so.
|
|
124
|
+
*/
|
|
125
|
+
export function propsTypeName(reactMajor) {
|
|
126
|
+
return reactMajor !== null && reactMajor >= 19
|
|
127
|
+
? "ComponentProps"
|
|
128
|
+
: "ComponentPropsWithoutRef";
|
|
129
|
+
}
|
|
130
|
+
function propsType(axes, tag, base) {
|
|
86
131
|
const extras = axes.map((a) => a.boolean
|
|
87
132
|
? ` ${a.prop}?: boolean;`
|
|
88
133
|
: ` ${a.prop}?: ${a.options.map((o) => `"${o}"`).join(" | ")};`);
|
|
89
134
|
if (extras.length === 0)
|
|
90
|
-
return
|
|
91
|
-
return
|
|
135
|
+
return `${base}<"${tag}">`;
|
|
136
|
+
return `${base}<"${tag}"> & {\n${extras.join("\n")}\n}`;
|
|
92
137
|
}
|
|
93
138
|
function dataAttrLines(axes) {
|
|
94
139
|
return axes
|
|
@@ -403,7 +448,7 @@ ${inner}
|
|
|
403
448
|
* </${comp}>
|
|
404
449
|
*/`;
|
|
405
450
|
}
|
|
406
|
-
function emitCssMode(slug, name, recipe, version) {
|
|
451
|
+
function emitCssMode(slug, name, recipe, version, props) {
|
|
407
452
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
408
453
|
const axes = axesOf(recipe.variants);
|
|
409
454
|
const comp = pascal(name);
|
|
@@ -418,11 +463,14 @@ function emitCssMode(slug, name, recipe, version) {
|
|
|
418
463
|
"className",
|
|
419
464
|
"...props",
|
|
420
465
|
].join(", ");
|
|
466
|
+
const control = partIsInteractive(part);
|
|
467
|
+
const partTag = control ? "button" : "div";
|
|
468
|
+
const partAttrs = control ? '\n type="button"' : "";
|
|
421
469
|
return `
|
|
422
470
|
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
423
|
-
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes,
|
|
471
|
+
export function ${partComp}({ ${partDestructure} }: ${propsType(partAxes, partTag, props)}) {
|
|
424
472
|
return (
|
|
425
|
-
|
|
473
|
+
<${partTag}${partAttrs}
|
|
426
474
|
className={${joinCls([`"ds-${name}-${kebab(partName)}"`, "className"])}}
|
|
427
475
|
${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
428
476
|
/>
|
|
@@ -432,9 +480,9 @@ ${dataAttrLines(partAxes)}${partAxes.length ? "\n" : ""} {...props}
|
|
|
432
480
|
return `${header(slug, name, version, "css")}
|
|
433
481
|
import "./${name}.css";
|
|
434
482
|
|
|
435
|
-
import type {
|
|
483
|
+
import type { ${props} } from "react";
|
|
436
484
|
|
|
437
|
-
type ${comp}Props = ${propsType(axes, tag)};
|
|
485
|
+
type ${comp}Props = ${propsType(axes, tag, props)};
|
|
438
486
|
|
|
439
487
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
440
488
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
@@ -444,7 +492,7 @@ ${rootJsx}
|
|
|
444
492
|
}
|
|
445
493
|
${parts.join("\n")}`;
|
|
446
494
|
}
|
|
447
|
-
function emitTailwindMode(slug, name, recipe, version) {
|
|
495
|
+
function emitTailwindMode(slug, name, recipe, version, props) {
|
|
448
496
|
const { tag, attrs, voidEl } = elementFor(name, recipe);
|
|
449
497
|
const axes = axesOf(recipe.variants);
|
|
450
498
|
const comp = pascal(name);
|
|
@@ -479,12 +527,12 @@ function emitTailwindMode(slug, name, recipe, version) {
|
|
|
479
527
|
].join(", ");
|
|
480
528
|
return `${header(slug, name, version, "tailwind")}
|
|
481
529
|
|
|
482
|
-
import type {
|
|
530
|
+
import type { ${props} } from "react";
|
|
483
531
|
|
|
484
532
|
const BASE = ${JSON.stringify(tailwindClassList(recipe, excluded))};
|
|
485
533
|
${[...variantConsts, ...booleanConsts].join("\n")}
|
|
486
534
|
|
|
487
|
-
type ${comp}Props = ${propsType(axes, tag)};
|
|
535
|
+
type ${comp}Props = ${propsType(axes, tag, props)};
|
|
488
536
|
|
|
489
537
|
${compositionHint(comp, name, recipe, voidEl)}
|
|
490
538
|
export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
@@ -498,31 +546,38 @@ export function ${comp}({ ${destructure} }: ${comp}Props) {
|
|
|
498
546
|
${Object.entries(recipe.parts ?? {})
|
|
499
547
|
.map(([partName, part]) => {
|
|
500
548
|
const partComp = `${comp}${pascal(partName)}`;
|
|
549
|
+
const control = partIsInteractive(part);
|
|
550
|
+
const partTag = control ? "button" : "div";
|
|
551
|
+
const partAttrs = control ? ' type="button"' : "";
|
|
501
552
|
return `
|
|
502
553
|
/** Part "${partName}" of ${comp} - compose it inside <${comp}>. */
|
|
503
|
-
export function ${partComp}({ className, ...props }:
|
|
554
|
+
export function ${partComp}({ className, ...props }: ${props}<"${partTag}">) {
|
|
504
555
|
return (
|
|
505
|
-
|
|
556
|
+
<${partTag}${partAttrs} className={${joinCls([JSON.stringify(tailwindClassList(part)), "className"])}} {...props} />
|
|
506
557
|
);
|
|
507
558
|
}`;
|
|
508
559
|
})
|
|
509
560
|
.join("\n")}`;
|
|
510
561
|
}
|
|
511
562
|
/** All files for one component, under `<componentsDir>/<name>/`. */
|
|
512
|
-
export function generateComponentFiles(slug, name, recipe, css, version, styles
|
|
563
|
+
export function generateComponentFiles(slug, name, recipe, css, version, styles,
|
|
564
|
+
/** Consumer's React major, read from its package.json. Null = unknown, which
|
|
565
|
+
* keeps the ref-less type rather than guessing in the unsafe direction. */
|
|
566
|
+
reactMajor = null) {
|
|
513
567
|
const comp = pascal(name);
|
|
514
568
|
const files = [];
|
|
569
|
+
const props = propsTypeName(reactMajor);
|
|
515
570
|
if (styles === "css") {
|
|
516
571
|
files.push({
|
|
517
572
|
filename: `${name}.tsx`,
|
|
518
|
-
code: `${emitCssMode(slug, name, recipe, version)}\n`,
|
|
573
|
+
code: `${emitCssMode(slug, name, recipe, version, props)}\n`,
|
|
519
574
|
});
|
|
520
575
|
files.push({ filename: `${name}.css`, code: `${css}\n` });
|
|
521
576
|
}
|
|
522
577
|
else {
|
|
523
578
|
files.push({
|
|
524
579
|
filename: `${name}.tsx`,
|
|
525
|
-
code: `${emitTailwindMode(slug, name, recipe, version)}\n`,
|
|
580
|
+
code: `${emitTailwindMode(slug, name, recipe, version, props)}\n`,
|
|
526
581
|
});
|
|
527
582
|
}
|
|
528
583
|
files.push({
|
package/dist/doctor/scan.js
CHANGED
|
@@ -31,11 +31,32 @@ const IGNORE_LINE = /^\s*(import|@import|\/\/|\*|\/\*)/;
|
|
|
31
31
|
* 3/6/8 digit run terminated by a non-hex character.
|
|
32
32
|
*/
|
|
33
33
|
const COLOR = /#[0-9a-fA-F]{8}\b|#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b|rgba?\([^)]*\)|hsla?\([^)]*\)/g;
|
|
34
|
-
/**
|
|
35
|
-
*
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
34
|
+
/**
|
|
35
|
+
* THE QUOTE THAT MADE HALF THE DRIFT INVISIBLE.
|
|
36
|
+
*
|
|
37
|
+
* Both patterns below used to require `property:` followed directly by a digit.
|
|
38
|
+
* CSS writes `padding: 2rem` and matches. JSX writes `padding: "2rem"`, and the
|
|
39
|
+
* quote ends the match before it starts - so a component built with an inline
|
|
40
|
+
* style object was scanned for colour and nothing else.
|
|
41
|
+
*
|
|
42
|
+
* Colour escaped it by luck: that pattern hunts `#hex` anywhere and never
|
|
43
|
+
* needed a property in front. Which is exactly why nobody noticed, because
|
|
44
|
+
* every test file had a colour in it and the colour always came back.
|
|
45
|
+
*
|
|
46
|
+
* Found 28/07 writing a file with `gap: "1.25rem"` and `padding: "2rem"` on
|
|
47
|
+
* purpose - both exact tokens of the system - and being told six colours, zero
|
|
48
|
+
* spacings.
|
|
49
|
+
*
|
|
50
|
+
* JSX also camelCases, so `borderRadius` has to be as welcome as
|
|
51
|
+
* `border-radius`.
|
|
52
|
+
*/
|
|
53
|
+
const OPEN = `["']?`;
|
|
54
|
+
/** `rounded-[14px]`, `border-radius: 14px`, `borderRadius: "14px"`. Zero and
|
|
55
|
+
* full pills are idiom, not drift - nobody tokenizes `0` or `9999px`. */
|
|
56
|
+
const RADIUS = new RegExp(`(?:border-?[Rr]adius\\s*:\\s*${OPEN}|rounded(?:-[a-z]+)?-\\[)(-?\\d*\\.?\\d+)(px|rem|em)`, "g");
|
|
57
|
+
/** Arbitrary spacing: `p-[18px]`, `gap-[7px]`, `margin: 18px`, `gap: "18px"`.
|
|
58
|
+
* The JSX side also writes `paddingLeft`, `marginTop` and friends. */
|
|
59
|
+
const SPACING = new RegExp(`(?:\\b[pmg](?:[trblxy])?-\\[|gap-\\[|(?:padding|margin|gap)(?:[A-Z][a-z]+)?\\s*:\\s*${OPEN})(-?\\d*\\.?\\d+)(px|rem)`, "g");
|
|
39
60
|
/** A font stack written by hand rather than taken from the type scale. */
|
|
40
61
|
const FONT = /font-family\s*:\s*([^;}\n]+)/g;
|
|
41
62
|
/**
|
|
@@ -252,7 +273,9 @@ export function scanSource(file, source, table) {
|
|
|
252
273
|
kind,
|
|
253
274
|
line: at,
|
|
254
275
|
literal,
|
|
255
|
-
|
|
276
|
+
// The kind is already known here, and without passing it the lookup
|
|
277
|
+
// answers a `gap` with a radius token.
|
|
278
|
+
token: tokenFor(table, literal, kind),
|
|
256
279
|
excerpt: clip(line),
|
|
257
280
|
});
|
|
258
281
|
};
|
package/dist/doctor/tokens.js
CHANGED
|
@@ -416,11 +416,35 @@ export function nearestToken(table, literal) {
|
|
|
416
416
|
const limit = unit === "rem" ? Math.max(n * 0.25, 0.5) : Math.max(n * 0.25, 8);
|
|
417
417
|
return best.delta <= limit ? best : null;
|
|
418
418
|
}
|
|
419
|
-
|
|
419
|
+
/**
|
|
420
|
+
* The family a token belongs to, from the drift it was found in.
|
|
421
|
+
*
|
|
422
|
+
* Without this the lookup is by VALUE alone, and a value belongs to more than
|
|
423
|
+
* one family: `1.25rem` is both `--ds-spacing-sm` and `--ds-radius-lg` in the
|
|
424
|
+
* same system. Measured 28/07 on `gap-[1.25rem]`, which was correctly counted as
|
|
425
|
+
* spacing and then told to use a radius token.
|
|
426
|
+
*
|
|
427
|
+
* That is worse than saying nothing. A tool that answers a gap with a corner
|
|
428
|
+
* radius is one a person stops reading, and this one has exactly one job that
|
|
429
|
+
* nobody else does: naming the right token.
|
|
430
|
+
*/
|
|
431
|
+
const FAMILY = {
|
|
432
|
+
color: "--ds-color-",
|
|
433
|
+
radius: "--ds-radius-",
|
|
434
|
+
spacing: "--ds-spacing-",
|
|
435
|
+
font: "--ds-typography-",
|
|
436
|
+
};
|
|
437
|
+
export function tokenFor(table, literal, kind) {
|
|
420
438
|
const hit = table.byValue.get(normalizeValue(literal));
|
|
421
439
|
if (!hit || hit.length === 0)
|
|
422
440
|
return null;
|
|
441
|
+
// Narrow to the family first, and only fall back to the whole set when the
|
|
442
|
+
// system has no token of that kind holding this value - a fallback is still
|
|
443
|
+
// better than silence, it just stops being a recommendation.
|
|
444
|
+
const prefix = kind ? FAMILY[kind] : undefined;
|
|
445
|
+
const family = prefix ? hit.filter((n) => n.startsWith(prefix)) : [];
|
|
446
|
+
const pool = family.length > 0 ? family : hit;
|
|
423
447
|
// Semantic roles name intent; primitives name a shelf. Prefer intent.
|
|
424
|
-
const semantic =
|
|
425
|
-
return semantic ??
|
|
448
|
+
const semantic = pool.find((n) => n.includes("-semantic-"));
|
|
449
|
+
return semantic ?? pool[0];
|
|
426
450
|
}
|
package/package.json
CHANGED