synthesisui 0.16.467 → 0.16.469

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.
@@ -1,4 +1,5 @@
1
1
  import { existsSync } from "node:fs";
2
+ import { speakInHisProject } from "../in-his-form.js";
2
3
  import { mkdir, readFile, writeFile } from "node:fs/promises";
3
4
  import { join } from "node:path";
4
5
  import { emitCn, readVocabulary } from "../cn-codegen.js";
@@ -413,12 +414,18 @@ export async function component(slug, name, opts) {
413
414
  */
414
415
  const nota = unreadComment(unreadForComponent(await readCensus(root), res.name));
415
416
  /** Os bytes finais, para o fingerprint lembrar EXATAMENTE o que foi a disco (T7). */
416
- const written = files.map((file) => ({
417
+ const drafted = files.map((file) => ({
417
418
  filename: file.filename,
418
419
  content: file.filename.endsWith(".tsx")
419
420
  ? `${nota}${file.code}`
420
421
  : file.code,
421
422
  }));
423
+ /** Num projeto SCSS, a folha sai em SCSS e na grafia dele - ver `scss-speech.ts` (27/09). */
424
+ /**
425
+ * NA GRAFIA DELE, E DEPOIS NA FORMA DELE - `scss-speech.ts` troca `var(--x)` pelo `$x` dele, e
426
+ * `in-his-form.ts` troca o nosso `ds-` e a folha global pelo formato que o projeto usa (28/09).
427
+ */
428
+ const written = await speakInHisProject(root, compDir, drafted, flavour, local, res.name);
422
429
  for (const f of written)
423
430
  await writeFile(join(compDir, f.filename), f.content, "utf8");
424
431
  await recordWritten(join(root, "_synthesisui", "ds", slug), local, written);
@@ -1,4 +1,6 @@
1
1
  import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { readStyleSheet, scssVariables } from "../doctor/scss-lens.js";
3
+ import { tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
2
4
  import { dirname, join, relative, resolve } from "node:path";
3
5
  import { describeIntent, intentOf, readProjectConfig } from "../config.js";
4
6
  import { describeFix, planFix, readerFor, writeFix, } from "../doctor/apply-fix.js";
@@ -431,17 +433,45 @@ measured) {
431
433
  for await (const file of walkAll(roots)) {
432
434
  if (!/\.(css|scss|sass|less)$/i.test(file))
433
435
  continue;
436
+ /**
437
+ * CRU, E NÃO PELA LENTE - a tabela é o que o doctor oferece como TROCA. Pela lente, `$gray_dark` viraria um
438
+ * `--gray_dark` que o navegador não conhece, e um `#555` num `.tsx` sairia com a sugestão de
439
+ * `var(--gray_dark)` - código que não pinta. O nome Sass continua dito, nunca escrito, por
440
+ * `aliases` (`their-names.ts`); a lente entra só na VARREDURA, onde conta o uso para a cobertura.
441
+ */
434
442
  const source = await readFile(file, "utf8").catch(() => "");
435
443
  sheets.set(file, source);
436
444
  css += `\n${source}`;
437
445
  }
438
- return buildTable({
446
+ const table = buildTable({
439
447
  css,
440
448
  source: "yours",
441
449
  sheets,
442
450
  rootPx: measured?.px,
443
451
  rootFrom: measured?.from ?? null,
444
452
  });
453
+ /** As `$variáveis` Sass, à parte da tabela de trocas - ver `TokenTable.sassNames` (27/09). */
454
+ const sassNames = new Map();
455
+ for (const [file, source] of sheets)
456
+ if (/\.(scss|sass)$/i.test(file))
457
+ for (const [n, v] of scssVariables(source)) {
458
+ const key = `--${n.slice(1).toLowerCase()}`;
459
+ if (!sassNames.has(key))
460
+ sassNames.set(key, v);
461
+ }
462
+ if (sassNames.size > 0)
463
+ table.sassNames = sassNames;
464
+ /** E o objeto de tokens em TypeScript - ver `TokenTable.tsNames` (27/09). */
465
+ const tsNames = new Map();
466
+ for await (const file of walkAll(roots))
467
+ if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
468
+ for (const o of tsTokenObjects(await readFile(file, "utf8").catch(() => "")))
469
+ for (const [n, v] of o.values)
470
+ if (!tsNames.has(n))
471
+ tsNames.set(n, v);
472
+ if (tsNames.size > 0)
473
+ table.tsNames = tsNames;
474
+ return table;
445
475
  }
446
476
  const KIND_LABEL = {
447
477
  color: "colour",
@@ -695,6 +725,15 @@ export async function doctor(opts) {
695
725
  */
696
726
  table = withTheirNames(installed.theirs, installed.theirs);
697
727
  }
728
+ /**
729
+ * AS `$VARIÁVEIS` DELE VALEM COM OU SEM SISTEMA NOSSO - ver `TokenTable.sassNames` (27/09). Elas moram na
730
+ * tabela DELE (`installed.theirs`), e com um sistema instalado a varredura usa a nossa: sem este repasse, um
731
+ * `$accent` num `.scss` não entrava em conta nenhuma.
732
+ */
733
+ if (installed.theirs.sassNames && !table.sassNames)
734
+ table = { ...table, sassNames: installed.theirs.sassNames };
735
+ if (installed.theirs.tsNames && !table.tsNames)
736
+ table = { ...table, tsNames: installed.theirs.tsNames };
698
737
  const hasSystem = table.byName.size > 0;
699
738
  /**
700
739
  * O NOME DELE, ONDE ANTES ESTAVA "the system".
@@ -724,12 +763,22 @@ export async function doctor(opts) {
724
763
  * Nobody debugs from 0%. They conclude the product does not work.
725
764
  */
726
765
  const skippedProjects = [];
766
+ /** Os objetos de tokens em TypeScript, para a lente da varredura - lidos da RAIZ, como o vocabulário. */
767
+ const tsObjects = [];
768
+ for await (const file of walkAll([root]))
769
+ if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
770
+ tsObjects.push(...tsTokenObjects(await readFile(file, "utf8").catch(() => "")));
727
771
  const tally = emptyTally();
728
772
  const internalSpecs = await internalSpecifiers(root);
729
773
  for await (const file of scopes.length > 0
730
774
  ? walkAll(scopes)
731
775
  : walk(root, skippedProjects)) {
732
- const src = await readFile(file, "utf8").catch(() => "");
776
+ /** Um `.scss` passa pela lente, ou `$accent` não conta como nome - ver `scss-lens.ts` (27/09). */
777
+ const src = /\.(scss|sass)$/i.test(file)
778
+ ? await readStyleSheet(file)
779
+ : tsObjects.length > 0 && /\.(tsx|jsx|ts)$/.test(file)
780
+ ? tsTokensAsVars(await readFile(file, "utf8").catch(() => ""), tsObjects)
781
+ : await readFile(file, "utf8").catch(() => "");
733
782
  if (!src)
734
783
  continue;
735
784
  const rel = relative(root, file);
@@ -1,4 +1,5 @@
1
1
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { speakInHisProject } from "../in-his-form.js";
2
3
  import { join } from "node:path";
3
4
  import { generateComponentFiles } from "../component-codegen.js";
4
5
  import { readProjectConfig, resolveRegistry } from "../config.js";
@@ -104,13 +105,15 @@ export async function generate(description, opts) {
104
105
  console.log(` The recipe and the compiled CSS are on disk either way - only the .tsx was not written.`);
105
106
  return;
106
107
  }
107
- for (const file of files) {
108
- await writeFile(join(compDir, file.filename), file.code, "utf8");
108
+ /** Na grafia e na forma dele - ver `speakInHisProject` (28/09). */
109
+ const spoken = await speakInHisProject(root, compDir, files.map((x) => ({ filename: x.filename, content: x.code })), flavourOf(res.name), res.name, res.name);
110
+ for (const file of spoken) {
111
+ await writeFile(join(compDir, file.filename), file.content, "utf8");
109
112
  }
110
113
  /** O fingerprint do que escrevemos - protege a edição dele no upgrade (T7). */
111
- await recordWritten(join(root, "_synthesisui", "ds", slug), res.name, files.map((x) => ({ filename: x.filename, content: x.code })));
114
+ await recordWritten(join(root, "_synthesisui", "ds", slug), res.name, spoken);
112
115
  materialized = true;
113
- console.log(`✓ ${config.componentsDir}/${res.name}/ → ${files.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
116
+ console.log(`✓ ${config.componentsDir}/${res.name}/ → ${spoken.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
114
117
  }
115
118
  console.log("");
116
119
  console.log("Use it:");
@@ -1,4 +1,6 @@
1
1
  import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { readStyleSheet } from "../doctor/scss-lens.js";
3
+ import { tsTokenObjects, tsTokensAsVars } from "../doctor/ts-tokens-lens.js";
2
4
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
3
5
  import { anatomyFromSketch } from "../anatomy-from-sketch.js";
4
6
  import { applyAnatomyPatch, hasEdits, } from "../anatomy-patch.js";
@@ -45,6 +47,7 @@ import { variantCallsIn } from "../doctor/variant-calls.js";
45
47
  import { transcribeVariants } from "../doctor/variant-read.js";
46
48
  import { frontierKind, packageRoot } from "../frontier-kind.js";
47
49
  import { keyframesInSheets } from "../global-keyframes.js";
50
+ import { templateClassVariants } from "../doctor/template-class-variants.js";
48
51
  import { globalClassesWorn, porNecessidade } from "../global-wear.js";
49
52
  import { withLibraryStructure } from "../library-structure.js";
50
53
  import { architectureGaps, mergeCensus } from "../merge-census.js";
@@ -189,7 +192,7 @@ async function sheetWithImports(path, seen = new Set(), depth = 0) {
189
192
  if (depth > 6 || seen.has(path))
190
193
  return { css: "", files: [] };
191
194
  seen.add(path);
192
- const body = await readFile(path, "utf8").catch(() => "");
195
+ const body = await readStyleSheet(path);
193
196
  if (!body)
194
197
  return { css: "", files: [] };
195
198
  let css = "";
@@ -275,7 +278,7 @@ alsoGlobalsFrom) {
275
278
  for await (const file of walkAll([root])) {
276
279
  if (!/\.(css|scss|sass|less)$/i.test(file))
277
280
  continue;
278
- const body = await readFile(file, "utf8").catch(() => "");
281
+ const body = await readStyleSheet(file);
279
282
  sheets.push({ file, body });
280
283
  }
281
284
  /**
@@ -291,7 +294,7 @@ alsoGlobalsFrom) {
291
294
  continue;
292
295
  if (file === root || file.startsWith(dentro))
293
296
  continue;
294
- const body = await readFile(file, "utf8").catch(() => "");
297
+ const body = await readStyleSheet(file);
295
298
  deForaDoEscopo.add(file);
296
299
  sheets.push({ file, body });
297
300
  }
@@ -552,7 +555,20 @@ export async function takeCensus(root, opts) {
552
555
  /** Todo arquivo de estilo do escopo, para o ledger - ver o parâmetro `into`. */
553
556
  const styleFiles = [];
554
557
  const { css, globals: globalSheets } = await harvestOwnCss(root, styleFiles, repoRoot);
555
- const table = buildTable({ css, source: "yours" });
558
+ /**
559
+ * O OBJETO DE TOKENS EM TYPESCRIPT ENTRA NO VOCABULÁRIO - ver `ts-tokens-lens.ts` (rodada 9, 27/09). O
560
+ * teste-import-atomico declarava cor, papel, espaço, raio e fonte em `ui/theme/tokens.ts`, e o censo saía com
561
+ * `declared: {}`. O objeto é lido como `--caminho: valor`, e os componentes que o usam por `style` são lidos
562
+ * pela mesma lente mais abaixo.
563
+ */
564
+ const tsObjects = [];
565
+ for await (const file of walkAll([root]))
566
+ if (/\.(ts|tsx)$/.test(file) && !/\.d\.ts$/.test(file))
567
+ tsObjects.push(...tsTokenObjects(await readFile(file, "utf8").catch(() => "")));
568
+ const tsRoot = tsObjects
569
+ .flatMap((o) => [...o.values].map(([n, v]) => `${n}: ${v};`))
570
+ .join(" ");
571
+ const table = buildTable({ css: tsRoot ? `${css}\n:root { ${tsRoot} }` : css, source: "yours" });
556
572
  const schemes = parseSchemeBlocks(css);
557
573
  const reports = [];
558
574
  const tally = emptyTally();
@@ -750,7 +766,9 @@ export async function takeCensus(root, opts) {
750
766
  */
751
767
  const progress = startProgress("Reading");
752
768
  for await (const file of walk(root)) {
753
- const src = await readFile(file, "utf8").catch(() => "");
769
+ const raw = await readFile(file, "utf8").catch(() => "");
770
+ /** Um `style` que lê o objeto de tokens é lido como variável CSS - ver `ts-tokens-lens.ts` (27/09). */
771
+ const src = tsObjects.length > 0 && /\.(tsx|jsx|ts)$/.test(file) ? tsTokensAsVars(raw, tsObjects) : raw;
754
772
  if (!src)
755
773
  continue;
756
774
  const rel = relative(root, file);
@@ -1192,7 +1210,11 @@ export async function takeCensus(root, opts) {
1192
1210
  ...fromModule?.dark,
1193
1211
  ...t.dark,
1194
1212
  };
1195
- const layers = [...(fromModule?.layers ?? []), ...v.layers];
1213
+ /** A classe global montada com texto (`btn--${variant}`) - ver `template-class-variants.ts` (27/09). */
1214
+ const fromTemplate = templateClassVariants(src, globalClasses);
1215
+ for (const cls of fromTemplate.claimed)
1216
+ globalClassesClaimed.add(cls);
1217
+ const layers = [...(fromModule?.layers ?? []), ...v.layers, ...fromTemplate.layers];
1196
1218
  const parts = { ...fromModule?.parts, ...t.parts };
1197
1219
  const tree = fromModule?.tree ?? [];
1198
1220
  const size = Object.keys(base).length +
@@ -1488,8 +1510,19 @@ export async function takeCensus(root, opts) {
1488
1510
  * `app/` and no `pages/`, so there is nothing to count and the count would be zero for
1489
1511
  * everything. An app has them, and then the number means something.
1490
1512
  */
1491
- const hasScreens = [...screensOf.values()].some((at) => [...at].some((s) => s.startsWith("app/") || s.startsWith("pages/")));
1492
- if (hasScreens) {
1513
+ const appScreens = new Set([...screensOf.values()].flatMap((at) => [...at].filter((s) => s.startsWith("app/") || s.startsWith("pages/"))));
1514
+ const hasScreens = appScreens.size > 0;
1515
+ /**
1516
+ * E MENOS TELAS DO QUE A LINHA TAMBÉM NÃO É TESTE - rodadas 6 a 10 do refinamento, 27/09.
1517
+ *
1518
+ * A pergunta da regra é "duas telas compartilham isto?", e num app de UMA tela ela não tem como ser
1519
+ * respondida: todo componente sai "composed on one screen only". Nos cinco estilos das rodadas
1520
+ * (Tailwind, variáveis CSS, CSS Modules, atômico, SCSS) o import tirou assim os componentes do
1521
+ * usuário, e o sistema nascia sem nenhum dele. É o mesmo raciocínio da biblioteca logo acima, do outro
1522
+ * lado: sem telas bastantes para comparar, não há compartilhamento para medir.
1523
+ */
1524
+ const measurable = appScreens.size >= SCREENS_FOR_SYSTEM;
1525
+ if (hasScreens && measurable) {
1493
1526
  const keep = [];
1494
1527
  for (const d of defined) {
1495
1528
  /**
@@ -1,4 +1,5 @@
1
1
  import { access, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { speakInHisProject } from "../in-his-form.js";
2
3
  import { basename, join } from "node:path";
3
4
  import { generateComponentFiles } from "../component-codegen.js";
4
5
  import { readProjectConfig, resolveRegistry } from "../config.js";
@@ -153,13 +154,15 @@ export async function refit(file, opts) {
153
154
  console.log(` The recipe is saved in your system's draft either way - only the .tsx was not written.`);
154
155
  return;
155
156
  }
156
- for (const f of files) {
157
- await writeFile(join(compDir, f.filename), f.code, "utf8");
157
+ /** Na grafia e na forma dele - ver `speakInHisProject` (28/09). */
158
+ const spoken = await speakInHisProject(root, compDir, files.map((x) => ({ filename: x.filename, content: x.code })), flavourOf(res.name), res.name, res.name);
159
+ for (const f of spoken) {
160
+ await writeFile(join(compDir, f.filename), f.content, "utf8");
158
161
  }
159
162
  /** O fingerprint do que escrevemos - protege a edição dele no upgrade (T7). */
160
- await recordWritten(join(root, "_synthesisui", "ds", slug), res.name, files.map((x) => ({ filename: x.filename, content: x.code })));
163
+ await recordWritten(join(root, "_synthesisui", "ds", slug), res.name, spoken);
161
164
  materialized = true;
162
- console.log(`✓ ${config.componentsDir}/${res.name}/ → ${files.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
165
+ console.log(`✓ ${config.componentsDir}/${res.name}/ → ${spoken.map((f) => f.filename).join(", ")} (styles: ${config.styles})`);
163
166
  }
164
167
  // The materialized component references this system's tokens - without the
165
168
  // install (tokens.css + scope) it renders unstyled. Say so, concretely.
@@ -1,4 +1,5 @@
1
1
  import { readdir, readFile, writeFile } from "node:fs/promises";
2
+ import { speakInHisProject } from "../in-his-form.js";
2
3
  import { join } from "node:path";
3
4
  import { pinnedHookVersion, wireAgent } from "../agent-wiring.js";
4
5
  import { agentsToMaintain } from "../agents-chosen.js";
@@ -428,7 +429,7 @@ export async function upgrade(asked, opts) {
428
429
  const tsxPath = join(componentsRoot, entry, `${entry}.tsx`);
429
430
  let head = "";
430
431
  try {
431
- head = (await readFile(tsxPath, "utf8")).slice(0, 300);
432
+ head = (await readFile(tsxPath, "utf8")).slice(0, 2000);
432
433
  }
433
434
  catch {
434
435
  continue; // not a materialized component folder
@@ -444,12 +445,15 @@ export async function upgrade(asked, opts) {
444
445
  if (keptEntries.has(entry))
445
446
  continue;
446
447
  try {
447
- const res = await fetchComponent(base, slug, entry);
448
+ /** A receita pelo cabeçalho, não pela pasta - ver `recipeNameOf` (28/09). */
449
+ const res = await fetchComponent(base, slug, recipeNameOf(head, entry));
448
450
  const { files } = generateComponentFiles(slug, res.name, res.recipe, res.css, res.version, flavourOf(res.name),
449
451
  // Both were missing here, and `upgrade` is the command that REWRITES
450
452
  // components somebody already has: without the convention it would have
451
453
  // taken a working component and stripped its styles.
452
- await reactMajorOf(root), res.classNames ?? (await readInstalledConvention(root, slug)), res.name, await readInstalledScheme(root, slug), tongue, themeVars);
454
+ await reactMajorOf(root), res.classNames ?? (await readInstalledConvention(root, slug)),
455
+ /** O nome local é a PASTA dele (`--as`), não a receita - senão sai `button.tsx` dentro de `button-gen/`. */
456
+ entry, await readInstalledScheme(root, slug), tongue, themeVars);
453
457
  /**
454
458
  * A NOTA DAS DECLARAÇÕES NÃO INTERPRETADAS SOBREVIVE AO UPGRADE.
455
459
  *
@@ -461,12 +465,13 @@ export async function upgrade(asked, opts) {
461
465
  */
462
466
  const nota = unreadComment(unreadForComponent(await readCensus(root), res.name));
463
467
  /** Os bytes finais, para o fingerprint lembrar EXATAMENTE o que foi a disco. */
464
- const finalFiles = files.map((file) => ({
468
+ /** Na grafia e na forma dele, como o `component` - ver `speakInHisProject` (28/09). */
469
+ const finalFiles = await speakInHisProject(root, join(componentsRoot, entry), files.map((file) => ({
465
470
  filename: file.filename,
466
471
  content: file.filename.endsWith(".tsx")
467
472
  ? `${nota}${file.code}`
468
473
  : file.code,
469
- }));
474
+ })), flavourOf(res.name), entry, res.name);
470
475
  for (const file of finalFiles) {
471
476
  await writeFile(join(componentsRoot, entry, file.filename), file.content, "utf8");
472
477
  }
@@ -567,3 +572,12 @@ export async function upgrade(asked, opts) {
567
572
  });
568
573
  console.log("");
569
574
  }
575
+ /**
576
+ * A RECEITA DE UM COMPONENTE GERADO, LIDA NO CABEÇALHO QUE O GERADOR ESCREVEU - revisão do #1629, 28/09.
577
+ *
578
+ * `--as button-gen` põe o componente numa pasta com o nome dele, e o `upgrade` pedia ao registry o componente
579
+ * `button-gen`: "Could not regenerate (...) (removed/renamed in v2?)". Sem cabeçalho, a pasta é a melhor pista.
580
+ */
581
+ export function recipeNameOf(head, entry) {
582
+ return /Generated by SynthesisUI - "([^"]+)" from the/.exec(head)?.[1] ?? entry;
583
+ }
@@ -1690,7 +1690,7 @@ themeVars) {
1690
1690
  filename: `${localName}.tsx`,
1691
1691
  code: `${emitCssMode(slug, name, recipe, version, props, convention, localName)}\n`,
1692
1692
  });
1693
- files.push({ filename: `${localName}.css`, code: `${css}\n` });
1693
+ files.push({ filename: `${localName}.css`, code: `${withoutOurScope(css)}\n` });
1694
1694
  }
1695
1695
  else {
1696
1696
  files.push({
@@ -1717,6 +1717,18 @@ themeVars) {
1717
1717
  };
1718
1718
  return speak(files, tongue, styles === "tailwind" ? "tailwind-class" : "stylesheet", root);
1719
1719
  }
1720
+ /**
1721
+ * A FOLHA DO COMPONENTE SEM O NOSSO ESCOPO - medido em 28/09, provando a saída SCSS da rodada 10.
1722
+ *
1723
+ * A folha compilada chega como `[data-ds="<slug>"] .ds-button { … }`, e nada no app dele tem `data-ds`: o
1724
+ * componente gerado não o põe (pôr o nosso escopo na árvore dele é o que `INV-GERAL-13` proíbe). O build
1725
+ * de um Next com o botão gerado deu `<button class="ds-button">` e a regra escopada sem nada que a casasse
1726
+ * - o botão sem estilo, em todo projeto sem Tailwind. A página do `template` nasce dentro de um invólucro
1727
+ * com o atributo e continua escopada; o componente não tem invólucro, e a classe `ds-*` já é só dele.
1728
+ */
1729
+ function withoutOurScope(css) {
1730
+ return css.replace(/\[data-ds=(["']?)[^\]"']+\1\]\s*/g, "");
1731
+ }
1720
1732
  /**
1721
1733
  * A TRADUÇÃO, SOBRE OS BYTES, UMA VEZ - e o destino é de quem GEROU o texto, não da extensão.
1722
1734
  *
@@ -18,6 +18,18 @@
18
18
  * so this returns all of them and the person picks which one has PRIORITY. That word is
19
19
  * theirs and it is the right one: the others are still true, they just do not decide.
20
20
  */
21
+ /**
22
+ * A PASTA DA FORMA `flat` - ela casa no lugar que TEM uma `components/`, e as peças moram DENTRO dela. Dizer
23
+ * "directly under `.`" num Next mandava o agente para a raiz (rodadas 6 a 10, 27/09). Quando o caminho já
24
+ * termina na pasta, ele é a resposta.
25
+ */
26
+ function flatFolder(where, folder) {
27
+ if (!folder)
28
+ return where;
29
+ if (where === "." || where === "")
30
+ return folder;
31
+ return where === folder || where.endsWith(`/${folder}`) ? where : `${where}/${folder}`;
32
+ }
21
33
  const SHAPES = [
22
34
  {
23
35
  kind: "atomic",
@@ -49,7 +61,7 @@ const SHAPES = [
49
61
  {
50
62
  kind: "flat",
51
63
  needs: [["components"]],
52
- describe: (_a, where) => `Every component sits directly under \`${where}\`, one folder deep. It is the shape that stays legible longest while a system is small, and the one to revisit when the folder passes about forty names.`,
64
+ describe: (a, where) => `Every component sits directly under \`${flatFolder(where, a.evidence[0])}\`, one folder deep. It is the shape that stays legible longest while a system is small, and the one to revisit when the folder passes about forty names.`,
53
65
  },
54
66
  ];
55
67
  /**
@@ -203,7 +215,14 @@ function explains(other, shape) {
203
215
  return other.kind === shape.kind || shape.kind === "flat";
204
216
  return (pathKey(other.root) === pathKey(shape.root) &&
205
217
  shape.kind === "flat" &&
206
- other.kind !== "flat");
218
+ other.kind !== "flat" &&
219
+ /**
220
+ * `colocated` NÃO EXPLICA A PASTA COMPARTILHADA - rodadas 6 a 10 do refinamento, 27/09. Num Next, a
221
+ * raiz tem `app/` e `components/`, as duas formas casam ali, e a `flat` sumia: o import dizia "BESIDE
222
+ * THE ROUTE" nos cinco estilos, com os componentes todos em `components/`. `colocated` diz onde moram
223
+ * as rotas; `flat` diz onde moram as peças que elas compartilham.
224
+ */
225
+ other.kind !== "colocated");
207
226
  }
208
227
  /**
209
228
  * O CAMINHO COMPARÁVEL - a raiz do escopo é escrita `.` pela caminhada, e comparada com `""` ela é
@@ -395,7 +414,15 @@ entries, scope) {
395
414
  const hasSrc = entries.some((e) => e.path === "src" ||
396
415
  e.path.startsWith("src/") ||
397
416
  (isRoot(e.path) && e.dirs.includes("src")));
398
- const trunk = `${base ? `${base}/` : ""}${hasSrc ? "src/components" : "components"}`;
417
+ /**
418
+ * O ESCOPO QUE JÁ É A PASTA DE COMPONENTES não ganha outra dentro dele - rodadas 6 e 8, 27/09: a
419
+ * sugestão saía `components/ui/components/{atoms, molecules, organisms}` e `components/components/…`, e o
420
+ * agente da rodada 8 chamou de "a slip in the tool".
421
+ */
422
+ const insideComponents = /(^|\/)components(\/|$)/.test(base);
423
+ const trunk = insideComponents
424
+ ? base
425
+ : `${base ? `${base}/` : ""}${hasSrc ? "src/components" : "components"}`;
399
426
  const fresh = `${trunk}/{atoms, molecules, organisms}`;
400
427
  /** Se a proposta é o que já existe, ela não é uma pasta nova - e oferecê-la seria repetir a opção 1. */
401
428
  return found.some((a) => componentHome(a, scope) === fresh) ? null : fresh;
@@ -447,6 +447,18 @@ export function partialCandidates(specifier) {
447
447
  `${dir}${base}.css`,
448
448
  ];
449
449
  }
450
+ /**
451
+ * OS VALORES QUE TIPAM UMA PROP - `kind?: "primary" | "secondary"` -> `["primary", "secondary"]`.
452
+ *
453
+ * Só a união de literais escrita no próprio arquivo: é o que torna a classe escolhida em tempo de execução
454
+ * legível sem adivinhar (rodadas 7 e 8 do refinamento, 27/09).
455
+ */
456
+ export function unionOfProp(source, prop) {
457
+ const m = new RegExp(`\\b${prop}\\??\\s*:\\s*((?:["'][^"']+["']\\s*\\|?\\s*)+)`).exec(source);
458
+ if (!m)
459
+ return [];
460
+ return [...m[1].matchAll(/["']([^"']+)["']/g)].map((v) => v[1]);
461
+ }
450
462
  /**
451
463
  * A generic in type position, not an element.
452
464
  *
@@ -614,11 +626,26 @@ export function readModuleUsage(source, local) {
614
626
  continue;
615
627
  }
616
628
  const body = event.body;
617
- if (bracket.test(body)) {
618
- // `styles[key]` picks a class at runtime, and the class is not in the source.
619
- // Impossible rather than hard, so it is not attempted.
620
- bracket.lastIndex = 0;
629
+ /**
630
+ * `styles[key]` ESCOLHE A CLASSE EM TEMPO DE EXECUÇÃO - e o nome dela está no arquivo, pela união que
631
+ * tipa a prop. Era pulado ("Impossible rather than hard"), e o Button e o Pill do teste-import-modules
632
+ * chegavam sem as cores dos variantes (rodadas 7 e 8, 27/09). Uma chave que não é uma prop tipada
633
+ * continua sem leitura.
634
+ */
635
+ const picked = [];
636
+ for (const m of body.matchAll(new RegExp(`\\b${local}\\[\\s*([A-Za-z_$][\\w$]*)\\s*\\]`, "g"))) {
637
+ const prop = m[1];
638
+ const values = unionOfProp(source, prop);
639
+ if (values.length === 0)
640
+ continue;
641
+ picked.push({
642
+ prop,
643
+ whenTrue: null,
644
+ whenFalse: null,
645
+ options: Object.fromEntries(values.map((v) => [v, v])),
646
+ });
621
647
  }
648
+ bracket.lastIndex = 0;
622
649
  ref.lastIndex = 0;
623
650
  const named = [...body.matchAll(ref)].map((m) => m[1]);
624
651
  if (named.length > 0) {
@@ -642,6 +669,7 @@ export function readModuleUsage(source, local) {
642
669
  // The resting class is the first one that is not a ternary branch.
643
670
  const inTernary = new Set(variants.flatMap((v) => [v.whenTrue, v.whenFalse].filter(Boolean)));
644
671
  const resting = named.find((n) => !inTernary.has(n)) ?? named[0];
672
+ variants.push(...picked);
645
673
  out.push({ className: resting, depth, tag: event.tag, variants });
646
674
  }
647
675
  depth += 1;
@@ -729,6 +757,7 @@ export function transcribeModule(read, usage, declared) {
729
757
  for (const [option, className] of [
730
758
  ["true", v.whenTrue],
731
759
  ["false", v.whenFalse],
760
+ ...Object.entries(v.options ?? {}),
732
761
  ]) {
733
762
  if (!className)
734
763
  continue;
@@ -736,10 +765,18 @@ export function transcribeModule(read, usage, declared) {
736
765
  if (!branch)
737
766
  continue;
738
767
  const style = resolve(branch.base);
739
- if (Object.keys(style).length === 0)
740
- continue;
741
- count += Object.keys(style).length;
742
- layers.push({ when: { variant: { [v.prop]: option } }, style });
768
+ if (Object.keys(style).length > 0) {
769
+ count += Object.keys(style).length;
770
+ layers.push({ when: { variant: { [v.prop]: option } }, style });
771
+ }
772
+ /** E os estados daquela classe (`.primary:hover`), sob a mesma opção - 27/09. */
773
+ for (const [state, block] of Object.entries(branch.states ?? {})) {
774
+ const st = resolve(block);
775
+ if (Object.keys(st).length === 0)
776
+ continue;
777
+ count += Object.keys(st).length;
778
+ layers.push({ when: { variant: { [v.prop]: option }, state }, style: st });
779
+ }
743
780
  }
744
781
  }
745
782
  }
@@ -495,6 +495,24 @@ function scanCore(file, source, table) {
495
495
  const findings = [];
496
496
  const phantoms = [];
497
497
  const ownDeclared = [];
498
+ /**
499
+ * OS NOMES SASS DELE, VISTOS PELA LENTE - rodada 10 do refinamento, 27/09. Num `.scss` lido por `scss-lens.ts`,
500
+ * `$accent` chega aqui como `var(--accent)`, e o nome Sass é conhecido pela tabela em `aliases` (o mapa que
501
+ * DIZ o nome dele e nunca o escreve). Um uso desses conta como vocabulário dele para a cobertura - que dizia
502
+ * "0% token coverage" com todo valor escrito por uma `$variável` - sem nunca entrar como troca.
503
+ */
504
+ const sassDeclared = new Map();
505
+ for (const [key, their] of table.aliases ?? [])
506
+ for (const n of [their.name, ...(their.also ?? [])])
507
+ if (n.startsWith("$"))
508
+ sassDeclared.set(`--${n.slice(1).toLowerCase()}`, key.split(":").slice(1).join(":"));
509
+ for (const [n, v] of table.sassNames ?? [])
510
+ if (!sassDeclared.has(n))
511
+ sassDeclared.set(n, v);
512
+ for (const [n, v] of table.tsNames ?? [])
513
+ if (!sassDeclared.has(n))
514
+ sassDeclared.set(n, v);
515
+ const sassCounted = new Set();
498
516
  const ownCandidates = new Map();
499
517
  let tokenUses = 0;
500
518
  // Reason by reason. Rolling two into "A or B" was the one place the report
@@ -570,6 +588,20 @@ function scanCore(file, source, table) {
570
588
  }
571
589
  for (const m of line.matchAll(ANY_VAR_USE)) {
572
590
  const name = m[1].toLowerCase();
591
+ /**
592
+ * O NOME SASS CONTA ANTES DO PULO - com um sistema instalado, `--accent` também pode estar na tabela
593
+ * do sistema, e aí o uso era pulado aqui e não entrava em conta nenhuma (0 do sistema, 12 dele, com
594
+ * 80 usos no arquivo). Um `$accent` escrito num `.scss` é vocabulário DELE.
595
+ */
596
+ const sass = sassDeclared.get(name);
597
+ if (sass !== undefined) {
598
+ ownCandidates.set(name, (ownCandidates.get(name) ?? 0) + 1);
599
+ if (!sassCounted.has(name)) {
600
+ sassCounted.add(name);
601
+ ownDeclared.push({ name, value: sass });
602
+ }
603
+ continue;
604
+ }
573
605
  if (isKnownToken(name, table))
574
606
  continue;
575
607
  ownCandidates.set(name, (ownCandidates.get(name) ?? 0) + 1);
@@ -0,0 +1,82 @@
1
+ /**
2
+ * A LENTE DO SCSS - as `$variáveis` dele lidas como o vocabulário que são (rodada 10 do refinamento, 27/09).
3
+ *
4
+ * O teste-import-scss declarava 20 `$variáveis` em `styles/_tokens.scss` e o censo saiu com `declared: {}`: o
5
+ * vocabulário só era lido de `--x` em `:root`/`@theme`, e o doctor dizia "0% token coverage" com todo valor
6
+ * novo escrito por uma `$variável`. Uma `$variável` some na compilação, mas é um nome que ELE deu a um valor -
7
+ * a mesma decisão que um `--x` registra.
8
+ *
9
+ * A LENTE SÓ EXISTE NA LEITURA. `$nome: valor;` no topo da folha vira `:root { --nome: valor; }`, e cada uso
10
+ * de `$nome` vira `var(--nome)`. Todo o resto do leitor - o vocabulário declarado, a cobertura, a ligação de
11
+ * um valor com o token dele - já sabe ler variável CSS, e passa a ler SCSS sem uma segunda implementação. O
12
+ * arquivo dele não é tocado; quem escreve de volta é o gerador, na grafia dele (`scss-speech.ts`).
13
+ *
14
+ * Só as declarações do NÍVEL DE FORA contam como vocabulário: uma `$x` dentro de um `@mixin` ou de uma regra
15
+ * é local, e chamar isso de token seria inventar um nome que ele não publicou.
16
+ */
17
+ const DECL = /^\s*\$([A-Za-z_][\w-]*)\s*:\s*([^;]+?)\s*(?:!default|!global)?\s*;/;
18
+ const USE = /\$([A-Za-z_][\w-]*)/g;
19
+ /** As declarações do nível de fora, na ordem, sem seguir aliases. */
20
+ function topLevel(scss) {
21
+ const out = [];
22
+ let depth = 0;
23
+ scss.split("\n").forEach((text, line) => {
24
+ if (depth === 0) {
25
+ const m = DECL.exec(text);
26
+ if (m)
27
+ out.push({ name: m[1], value: m[2].trim(), line });
28
+ }
29
+ for (const ch of text) {
30
+ if (ch === "{")
31
+ depth += 1;
32
+ else if (ch === "}")
33
+ depth = Math.max(0, depth - 1);
34
+ }
35
+ });
36
+ return out;
37
+ }
38
+ /** `$nome` -> o valor, com os aliases dele seguidos (`$accent: $teal-500` -> `#0e9f91`). */
39
+ export function scssVariables(scss) {
40
+ const raw = new Map(topLevel(scss).map((d) => [`$${d.name}`, d.value]));
41
+ const out = new Map();
42
+ for (const name of raw.keys()) {
43
+ const seen = new Set([name]);
44
+ let value = raw.get(name);
45
+ for (let ref = /^\$([A-Za-z_][\w-]*)$/.exec(value); ref; ref = /^\$([A-Za-z_][\w-]*)$/.exec(value)) {
46
+ const next = raw.get(`$${ref[1]}`);
47
+ if (next === undefined || seen.has(`$${ref[1]}`))
48
+ break;
49
+ seen.add(`$${ref[1]}`);
50
+ value = next;
51
+ }
52
+ out.set(name, value);
53
+ }
54
+ return out;
55
+ }
56
+ /** A folha como o leitor de CSS a entende - ver o cabeçalho. Sem `$`, ela volta byte a byte. */
57
+ export function scssAsCustomProperties(scss) {
58
+ if (!scss.includes("$"))
59
+ return scss;
60
+ const decls = new Map(topLevel(scss).map((d) => [d.line, d]));
61
+ return scss
62
+ .split("\n")
63
+ .map((text, line) => {
64
+ const d = decls.get(line);
65
+ if (d)
66
+ return `:root { --${d.name}: ${d.value.replace(USE, "var(--$1)")}; }`;
67
+ if (/^\s*@(use|forward|import)\b/.test(text))
68
+ return text;
69
+ return text.replace(USE, "var(--$1)");
70
+ })
71
+ .join("\n");
72
+ }
73
+ /**
74
+ * UMA FOLHA DELE, LIDA COMO O LEITOR A ENTENDE - a porta única da lente. Todo leitor de folha do usuário
75
+ * passa por aqui: o censo, o vocabulário do gerador, o `their-vars`, o doctor e o fim do turno. Um leitor
76
+ * que lesse o `.scss` cru veria "0% token coverage" onde os outros veem o vocabulário dele.
77
+ */
78
+ export async function readStyleSheet(file) {
79
+ const { readFile } = await import("node:fs/promises");
80
+ const text = await readFile(file, "utf8").catch(() => "");
81
+ return /\.(scss|sass)$/i.test(file) ? scssAsCustomProperties(text) : text;
82
+ }
@@ -0,0 +1,28 @@
1
+ import { unionOfProp } from "./css-modules.js";
2
+ const PIECE = /([A-Za-z][\w-]*?)\$\{\s*([A-Za-z_$][\w$]*)\s*\}/g;
3
+ export function templateClassVariants(src, classes) {
4
+ const layers = [];
5
+ const claimed = new Set();
6
+ const seen = new Set();
7
+ for (const tpl of src.matchAll(/className=\{`([^`]*)`\}/g)) {
8
+ for (const m of tpl[1].matchAll(PIECE)) {
9
+ const prefix = m[1];
10
+ const prop = m[2];
11
+ if (seen.has(`${prefix}|${prop}`))
12
+ continue;
13
+ seen.add(`${prefix}|${prop}`);
14
+ for (const value of unionOfProp(src, prop)) {
15
+ const rule = classes.get(`${prefix}${value}`);
16
+ if (!rule)
17
+ continue;
18
+ claimed.add(`${prefix}${value}`);
19
+ if (Object.keys(rule.base).length > 0)
20
+ layers.push({ when: { variant: { [prop]: value } }, style: { ...rule.base } });
21
+ for (const [state, block] of Object.entries(rule.states))
22
+ if (Object.keys(block).length > 0)
23
+ layers.push({ when: { variant: { [prop]: value }, state }, style: { ...block } });
24
+ }
25
+ }
26
+ }
27
+ return { layers, claimed: [...claimed] };
28
+ }
@@ -0,0 +1,137 @@
1
+ const kebab = (k) => k.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2
+ /** Um literal de objeto a partir de `{`, ou `null` quando aparece algo que não é literal. */
3
+ function parseObject(s, start) {
4
+ let i = start + 1;
5
+ const tree = {};
6
+ const ws = () => {
7
+ for (;;) {
8
+ while (i < s.length && /\s/.test(s[i]))
9
+ i += 1;
10
+ if (s.startsWith("//", i))
11
+ i = s.indexOf("\n", i) === -1 ? s.length : s.indexOf("\n", i);
12
+ else if (s.startsWith("/*", i))
13
+ i = s.indexOf("*/", i) === -1 ? s.length : s.indexOf("*/", i) + 2;
14
+ else
15
+ return;
16
+ }
17
+ };
18
+ const str = () => {
19
+ const q = s[i];
20
+ if (q !== '"' && q !== "'")
21
+ return null;
22
+ const end = s.indexOf(q, i + 1);
23
+ if (end === -1)
24
+ return null;
25
+ const out = s.slice(i + 1, end);
26
+ i = end + 1;
27
+ return out;
28
+ };
29
+ for (;;) {
30
+ ws();
31
+ if (s[i] === "}")
32
+ return { tree, end: i + 1 };
33
+ let key = str();
34
+ if (key === null) {
35
+ const m = /^[A-Za-z_$][\w$]*|^\d+/.exec(s.slice(i));
36
+ if (!m)
37
+ return null;
38
+ key = m[0];
39
+ i += key.length;
40
+ }
41
+ ws();
42
+ if (s[i] !== ":")
43
+ return null;
44
+ i += 1;
45
+ ws();
46
+ if (s[i] === "{") {
47
+ const inner = parseObject(s, i);
48
+ if (!inner)
49
+ return null;
50
+ tree[key] = inner.tree;
51
+ i = inner.end;
52
+ }
53
+ else {
54
+ const text = str();
55
+ if (text !== null)
56
+ tree[key] = text;
57
+ else {
58
+ const m = /^-?\d+(\.\d+)?/.exec(s.slice(i));
59
+ if (!m)
60
+ return null;
61
+ tree[key] = Number(m[0]);
62
+ i += m[0].length;
63
+ }
64
+ }
65
+ ws();
66
+ if (s[i] === ",")
67
+ i += 1;
68
+ }
69
+ }
70
+ const DESIGN = /^(#[0-9a-f]{3,8}|rgba?\(|hsla?\(|oklch\(|cubic-bezier\(|-?\d+(\.\d+)?(px|rem|em|%|ms|s)\b)|,|['"]/i;
71
+ function flatten(tree, prefix, out) {
72
+ for (const [k, v] of Object.entries(tree)) {
73
+ const path = [...prefix, kebab(k)];
74
+ if (typeof v === "object")
75
+ flatten(v, path, out);
76
+ else
77
+ out.set(`--${path.join("-")}`, typeof v === "number" ? `${v}px` : v);
78
+ }
79
+ }
80
+ /** Os objetos exportados que seguram valores de design - um objeto de rotas ou de textos não conta. */
81
+ export function tsTokenObjects(source) {
82
+ const out = [];
83
+ for (const m of source.matchAll(/export\s+const\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*\{/g)) {
84
+ const parsed = parseObject(source, (m.index ?? 0) + m[0].length - 1);
85
+ if (!parsed)
86
+ continue;
87
+ const values = new Map();
88
+ flatten(parsed.tree, [], values);
89
+ const leaves = [...values.values()];
90
+ const design = leaves.filter((v) => DESIGN.test(v) || /^\d+(\.\d+)?px$/.test(v)).length;
91
+ if (leaves.length >= 4 && design * 10 >= leaves.length * 6)
92
+ out.push({ name: m[1], values });
93
+ }
94
+ return out;
95
+ }
96
+ /** Os nomes locais pelos quais este arquivo importa cada objeto de tokens - `{ tokens as t }` -> `t`. */
97
+ function localNames(source, objects) {
98
+ const out = new Map();
99
+ for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*["'][^"']+["']/g))
100
+ for (const part of m[1].split(",")) {
101
+ const [imported, local] = part.trim().split(/\s+as\s+/).map((x) => x.trim());
102
+ const found = objects.find((o) => o.name === imported);
103
+ if (found && imported)
104
+ out.set(local || imported, found);
105
+ }
106
+ return out;
107
+ }
108
+ /** O arquivo como o leitor de estilo o entende - ver o cabeçalho. Sem objeto importado, ele volta igual. */
109
+ export function tsTokensAsVars(source, objects) {
110
+ const locals = localNames(source, objects);
111
+ if (locals.size === 0)
112
+ return source;
113
+ const names = [...locals.keys()].map((n) => n.replace(/\$/g, "\\$")).join("|");
114
+ const member = new RegExp(`\\b(${names})\\.([A-Za-z_$][\\w$.]*)`);
115
+ const varOf = (local, path) => {
116
+ const key = `--${path.split(".").map(kebab).join("-")}`;
117
+ return locals.get(local)?.values.has(key) ? `var(${key})` : null;
118
+ };
119
+ let out = source.replace(/`([^`]*)`/g, (whole, body) => {
120
+ if (!body.includes("${"))
121
+ return whole;
122
+ let ok = true;
123
+ const text = body.replace(/\$\{([^}]*)\}([a-z%]*)/g, (_p, expr) => {
124
+ const m = new RegExp(`^\\s*${member.source}\\s*$`).exec(expr);
125
+ const v = m ? varOf(m[1], m[2]) : null;
126
+ if (!v)
127
+ ok = false;
128
+ return v ?? "";
129
+ });
130
+ return ok ? `"${text}"` : whole;
131
+ });
132
+ out = out.replace(new RegExp(`(:\\s*)${member.source}(?=\\s*[,}\\n])`, "g"), (whole, lead, local, path) => {
133
+ const v = varOf(local, path);
134
+ return v ? `${lead}"${v}"` : whole;
135
+ });
136
+ return out;
137
+ }
@@ -0,0 +1,133 @@
1
+ const kebab = (v) => v.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
2
+ const camel = (v) => v.replace(/-([a-z0-9])/g, (_m, c) => c.toUpperCase());
3
+ const escape = (v) => v.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
4
+ export function inHisForm(files, form,
5
+ /** O nome que o componente ganha no projeto dele (`--as`), de onde sai a classe numa folha global. */
6
+ componentName,
7
+ /** O nome da receita, de onde sai o nosso endereço interno (`ds-<receita>`). Ausente, é o mesmo. */
8
+ recipeName) {
9
+ if (form.kind === "utility")
10
+ return files;
11
+ const root = `ds-${kebab(recipeName ?? componentName)}`;
12
+ const ours = new Set();
13
+ for (const f of files)
14
+ for (const m of f.content.matchAll(new RegExp(`\\b${escape(root)}(?:-[a-z0-9-]*[a-z0-9])?(?![\\w-])`, "g")))
15
+ ours.add(m[0]);
16
+ /** A mais longa primeiro, para `ds-button-icon-left` não virar `<root>-icon-left`. */
17
+ const classes = [...ours].sort((a, b) => b.length - a.length);
18
+ /**
19
+ * NUMA FOLHA GLOBAL, UM NOME QUE ELE JÁ USA SE MISTURA COM O DELE - provado em 28/09: o teste-import-css-vars
20
+ * já declara `.card` e `.card__title`. Um nome tomado ganha um número, o primeiro livre.
21
+ */
22
+ /** O prefixo dele, quando a folha global tem um (`acme-card`) - medido pelo censo (`classStyle.prefix`). */
23
+ const own = form.kind === "global" ? `${form.prefix ?? ""}${kebab(componentName)}` : kebab(componentName);
24
+ let base = own;
25
+ if (form.kind === "global" && form.taken) {
26
+ const free = (b) => !form.taken?.has(b);
27
+ for (let n = 2; !free(base); n += 1)
28
+ base = `${own}-${n}`;
29
+ }
30
+ const target = (cls) => {
31
+ const part = cls === root ? null : cls.slice(root.length + 1);
32
+ if (form.kind === "modules")
33
+ return part ? camel(part) : "root";
34
+ return part ? `${base}${form.partSeparator}${part}` : base;
35
+ };
36
+ const sheetOf = (f) => f.filename.endsWith(".css") || f.filename.endsWith(".scss");
37
+ const sheet = files.find(sheetOf);
38
+ const newSheet = sheet && form.kind === "modules"
39
+ ? sheet.filename.replace(/\.(s?css)$/, `.module.${form.ext}`)
40
+ : sheet?.filename;
41
+ return files.map((f) => {
42
+ let content = f.content;
43
+ if (sheetOf(f)) {
44
+ for (const cls of classes)
45
+ content = content.replace(new RegExp(`\\.${escape(cls)}(?![\\w-])`, "g"), `.${target(cls)}`);
46
+ return { ...f, filename: newSheet ?? f.filename, content };
47
+ }
48
+ if (!/\.(tsx|jsx)$/.test(f.filename))
49
+ return f;
50
+ if (form.kind === "modules") {
51
+ for (const cls of classes) {
52
+ const key = target(cls);
53
+ content = content
54
+ .replace(new RegExp(`className="${escape(cls)}"`, "g"), `className={styles.${key}}`)
55
+ .replace(new RegExp(`"${escape(cls)}"`, "g"), `styles.${key}`);
56
+ }
57
+ if (sheet && newSheet)
58
+ content = content.replace(new RegExp(`import\\s+["']\\./${escape(sheet.filename)}["'];?`), `import styles from "./${newSheet}";`);
59
+ }
60
+ else {
61
+ for (const cls of classes)
62
+ content = content.replace(new RegExp(`"${escape(cls)}"`, "g"), `"${target(cls)}"`);
63
+ }
64
+ return { ...f, content };
65
+ });
66
+ }
67
+ /**
68
+ * A FORMA DO PROJETO DELE, MEDIDA - e só num projeto DELE. O censo (`_synthesisui/census.json`) só existe
69
+ * depois de um import, que é quando o projeto é dele; num projeto novo nascido de um sistema nosso não há
70
+ * censo, e o gerador escreve na forma do sistema, como sempre (a regra de 27/09, `init` do fork).
71
+ *
72
+ * `utility` com zero utilitário é a leitura que o censo faz de um projeto BEM sem prefixo comum, ou de um de
73
+ * `style` inline (rodadas 7 e 9) - ali não há Tailwind, e o que ele tem é folha global.
74
+ */
75
+ export async function hisFormOf(root, flavour) {
76
+ if (flavour === "tailwind")
77
+ return { kind: "utility" };
78
+ const { readFile, readdir } = await import("node:fs/promises");
79
+ const { join } = await import("node:path");
80
+ const raw = await readFile(join(root, "_synthesisui", "census.json"), "utf8").catch(() => "");
81
+ if (!raw)
82
+ return { kind: "utility" };
83
+ const census = JSON.parse(raw);
84
+ const style = census.classStyle;
85
+ const sheets = [];
86
+ const walk = async (dir) => {
87
+ for (const e of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
88
+ if (e.name.startsWith(".") || ["node_modules", ".next", "_synthesisui", "dist", "build"].includes(e.name))
89
+ continue;
90
+ const p = join(dir, e.name);
91
+ if (e.isDirectory())
92
+ await walk(p);
93
+ else if (/\.(css|scss|sass)$/i.test(e.name))
94
+ sheets.push(p);
95
+ }
96
+ };
97
+ await walk(root);
98
+ if (style?.kind === "modules")
99
+ return { kind: "modules", ext: sheets.some((s) => /\.module\.(scss|sass)$/i.test(s)) ? "scss" : "css" };
100
+ /** As classes que as folhas DELE já declaram - nenhuma delas pode ser reusada por um componente gerado. */
101
+ const taken = new Set();
102
+ let bem = false;
103
+ for (const s of sheets) {
104
+ if (/\.module\./i.test(s))
105
+ continue;
106
+ const css = await readFile(s, "utf8").catch(() => "");
107
+ for (const m of css.matchAll(/\.(-?[_a-zA-Z][\w-]*)/g))
108
+ taken.add(m[1]);
109
+ if (/\.[a-z][\w-]*__[a-z]/i.test(css))
110
+ bem = true;
111
+ }
112
+ if (style?.kind === "global")
113
+ return { kind: "global", partSeparator: style.partSeparator || "-", prefix: style.prefix || "", taken };
114
+ if (style?.kind === "utility" && (style.utilities ?? 0) === 0)
115
+ return { kind: "global", partSeparator: bem ? "__" : "-", taken };
116
+ return { kind: "utility" };
117
+ }
118
+ /**
119
+ * A PORTA ÚNICA DA ESCRITA NO PROJETO DELE - a grafia (`scss-speech.ts`) e depois a forma (`inHisForm`).
120
+ *
121
+ * Todo comando que grava um componente gerado passa por aqui: `component`, `upgrade`, `refit` e `generate`.
122
+ * Revisão do #1629, 28/09: o `upgrade` regenerava direto do gerador, e num projeto de CSS Modules reescreveria
123
+ * o `.tsx` para importar um `button.css` global com `ds-button` - o componente de volta à nossa forma, e sem a
124
+ * folha dele. Um caminho que escreve sem passar por aqui é um caminho que escreve na nossa forma.
125
+ */
126
+ export async function speakInHisProject(root, compDir, files, flavour, localName, recipeName) {
127
+ if (flavour === "tailwind")
128
+ return files;
129
+ const { relative } = await import("node:path");
130
+ const { inScssSpeech, scssVocabularyOf } = await import("./scss-speech.js");
131
+ const spoken = inScssSpeech(files, await scssVocabularyOf(root), relative(root, compDir));
132
+ return inHisForm(spoken, await hisFormOf(root, flavour), localName, recipeName);
133
+ }
@@ -281,7 +281,15 @@
281
281
  * que o sistema não tem, e o `.lock` de um sistema nunca medido num código nasce com `reading: "none"` -
282
282
  * rodada 5 do refinamento. Uma pasta escrita antes ensina `.ds-table` ao agente.
283
283
  */
284
- export const MATERIALISER_SINCE = "0.16.467";
284
+ /**
285
+ * 0.16.467 -> 0.16.469 em 28/09, e o passo 1 dá **SIM**: o componente gerado em modo CSS passa a sair sem o
286
+ * nosso `[data-ds]` - medido num build, a regra escopada não casava com nada no app dele e o botão ficava sem
287
+ * estilo, em todo projeto sem Tailwind -, e num projeto SCSS a folha sai `.scss`, com o `@use` da folha de
288
+ * tokens dele e as `$variáveis` dele (rodada 10 do refinamento). Um componente escrito antes está sem estilo.
289
+ * Na mesma versão, o componente sai na FORMA do projeto dele: `.module.(s)css` com `styles.root` onde ele usa
290
+ * CSS Modules, a classe sem o nosso `ds-` onde ele usa folha global (a linha do dono, 28/09 00:49).
291
+ */
292
+ export const MATERIALISER_SINCE = "0.16.469";
285
293
  /**
286
294
  * A ÚLTIMA VERSÃO EM QUE O QUE O HOOK RODA MUDOU.
287
295
  *
@@ -897,7 +905,17 @@ export const CHECKER_SINCE = "0.16.467";
897
905
  * deveriam estar lá. Quem tem censo gravado precisa de um `sync` para a fila de trabalho dele ser a
898
906
  * real; ver o livro-razão de `corpus.spec.ts`.
899
907
  */
900
- export const READER_SINCE = "0.16.440";
908
+ /**
909
+ * 0.16.440 -> 0.16.468 em 27/09, e o passo 1 dá **SIM**: num app de uma tela só a regra de duas telas deixa
910
+ * de tirar os componentes dele - rodadas 6 a 10 do refinamento, nos cinco estilos o sistema nascia sem
911
+ * nenhum componente do usuário. Um censo lido antes guardou essa lista vazia, e só remedir a enche.
912
+ */
913
+ /**
914
+ * 0.16.468 -> 0.16.469 em 28/09, e o passo 1 dá **SIM**: o censo lê as `$variáveis` do SCSS e o objeto de tokens
915
+ * em TypeScript como o vocabulário dele, e a classe escolhida em tempo de execução pela união que tipa a prop
916
+ * (rodadas 7 a 10 do refinamento). Um censo lido antes guardou `declared: {}` para esses projetos.
917
+ */
918
+ export const READER_SINCE = "0.16.469";
901
919
  /**
902
920
  * O QUE ESTÁ INSTALADO AQUI FICOU PARA TRÁS - e as DUAS condições que fazem isso ser verdade.
903
921
  *
@@ -14,6 +14,7 @@
14
14
  * emits. It renders completely unstyled, and nothing anywhere reports a problem.
15
15
  */
16
16
  import { readdir, readFile } from "node:fs/promises";
17
+ import { readStyleSheet } from "./doctor/scss-lens.js";
17
18
  import { join, relative, sep } from "node:path";
18
19
  import { DEFAULT_CONVENTION, ThemeVocab, } from "./component-codegen.js";
19
20
  /**
@@ -344,8 +345,8 @@ export async function themeVocabOf(root, slug, names) {
344
345
  const path = join(at, entry.name);
345
346
  if (entry.isDirectory())
346
347
  await walk(path);
347
- else if (/\.css$/i.test(entry.name)) {
348
- const css = await readFile(path, "utf8").catch(() => "");
348
+ else if (/\.(css|scss|sass)$/i.test(entry.name)) {
349
+ const css = await readStyleSheet(path);
349
350
  if (!THEME_BLOCK.test(css) && !/:root\b/.test(css))
350
351
  continue;
351
352
  for (const [n, v] of declarationsIn(css, /--(?!ds-)[a-zA-Z0-9_-]+/))
@@ -0,0 +1,61 @@
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join, relative } from "node:path";
3
+ import { scssVariables } from "./doctor/scss-lens.js";
4
+ const SKIP = new Set(["node_modules", ".next", "dist", "build", "out", "_synthesisui", "coverage"]);
5
+ /** O vocabulário SCSS do projeto: a folha que declara mais `$variáveis` no nível de fora. */
6
+ export async function scssVocabularyOf(root) {
7
+ let best = null;
8
+ const walk = async (dir) => {
9
+ for (const entry of await readdir(dir, { withFileTypes: true }).catch(() => [])) {
10
+ if (entry.name.startsWith(".") || SKIP.has(entry.name))
11
+ continue;
12
+ const path = join(dir, entry.name);
13
+ if (entry.isDirectory())
14
+ await walk(path);
15
+ else if (/\.(scss|sass)$/i.test(entry.name) && !/\.module\./i.test(entry.name)) {
16
+ const vars = scssVariables(await readFile(path, "utf8").catch(() => ""));
17
+ if (vars.size > (best?.vars.size ?? 0))
18
+ best = { vars, tokensFile: relative(root, path).split("\\").join("/") };
19
+ }
20
+ }
21
+ };
22
+ await walk(root);
23
+ return best;
24
+ }
25
+ /** `styles/_tokens.scss`, visto de `components/button` -> `../../styles/tokens`. */
26
+ function usePath(tokensFile, compDir) {
27
+ const bare = tokensFile.replace(/(^|\/)_([^/]+)$/, "$1$2").replace(/\.(scss|sass)$/i, "");
28
+ const rel = relative(compDir, bare).split("\\").join("/");
29
+ return rel.startsWith(".") ? rel : `./${rel}`;
30
+ }
31
+ export function inScssSpeech(files, vocab, compDir) {
32
+ if (!vocab || vocab.vars.size === 0)
33
+ return files;
34
+ const byValue = new Map();
35
+ for (const [name, value] of vocab.vars) {
36
+ const k = value.trim().toLowerCase();
37
+ byValue.set(k, [...(byValue.get(k) ?? []), name]);
38
+ }
39
+ const renamed = new Map();
40
+ const out = files.map((f) => {
41
+ if (!f.filename.endsWith(".css"))
42
+ return f;
43
+ const filename = f.filename.replace(/\.css$/, ".scss");
44
+ renamed.set(f.filename, filename);
45
+ const body = f.content
46
+ .replace(/var\(\s*--([A-Za-z_][\w-]*)\s*\)/g, (whole, n) => vocab.vars.has(`$${n}`) ? `$${n}` : whole)
47
+ .replace(/:\s*([^;{}]+?)\s*;/g, (whole, value) => {
48
+ const hits = byValue.get(value.trim().toLowerCase());
49
+ return hits && hits.length === 1 ? `: ${hits[0]};` : whole;
50
+ });
51
+ return { ...f, filename, content: `@use "${usePath(vocab.tokensFile, compDir)}" as *;\n\n${body}` };
52
+ });
53
+ return out.map((f) => {
54
+ if (!/\.(tsx|ts|jsx|js)$/.test(f.filename))
55
+ return f;
56
+ let content = f.content;
57
+ for (const [from, to] of renamed)
58
+ content = content.split(`./${from}`).join(`./${to}`);
59
+ return content === f.content ? f : { ...f, content };
60
+ });
61
+ }
@@ -107,7 +107,16 @@ export async function measuredStyle(root) {
107
107
  return {};
108
108
  try {
109
109
  const census = JSON.parse(raw);
110
- const kind = census.classStyle?.kind;
110
+ /**
111
+ * `utility` COM ZERO UTILITÁRIO NÃO É TAILWIND - rodada 7 do refinamento, provado em 28/09. É a leitura que o
112
+ * censo faz de um projeto BEM sem prefixo comum, ou de um de `style` inline: o teste-import-css-vars não tem
113
+ * Tailwind, e o `component` saía em utilitários. Com zero contados, o que ele escreve é folha global. O
114
+ * `utility` que manda Tailwind mesmo sem a dependência na raiz (o monorepo) continua valendo - ali há
115
+ * utilitários contados.
116
+ */
117
+ const kind = census.classStyle?.kind === "utility" && (census.classStyle.utilities ?? 0) === 0
118
+ ? "global"
119
+ : census.classStyle?.kind;
111
120
  return {
112
121
  byComponent: census.coverage?.byComponent,
113
122
  classStyle: kind === "global" || kind === "modules" || kind === "utility"
@@ -1,4 +1,5 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
+ import { readStyleSheet } from "./doctor/scss-lens.js";
2
3
  import { join } from "node:path";
3
4
  import { theirNames } from "./doctor/their-names.js";
4
5
  import { buildTable, normalizeValue, } from "./doctor/tokens.js";
@@ -235,7 +236,7 @@ async function harvestTheirCss(root) {
235
236
  if (entry.isDirectory())
236
237
  await walk(path);
237
238
  else if (/\.(css|scss|sass|less)$/i.test(entry.name)) {
238
- const source = await readFile(path, "utf8").catch(() => "");
239
+ const source = await readStyleSheet(path);
239
240
  sheets.set(path, source);
240
241
  css += `\n${source}`;
241
242
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "synthesisui",
3
- "version": "0.16.467",
3
+ "version": "0.16.469",
4
4
  "description": "Bring SynthesisUI design systems into any project - tokens, typed components, whole pages and an agent-ready CLAUDE.md manifest.",
5
5
  "type": "module",
6
6
  "bin": {