@terrazzo/cli 2.0.0-beta.2 → 2.0.0-beta.4

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { fileURLToPath, pathToFileURL } from "node:url";
3
3
  import { build, defineConfig as defineConfig$1, parse } from "@terrazzo/parser";
4
- import fs, { createReadStream, createWriteStream } from "node:fs";
4
+ import fs from "node:fs";
5
5
  import path from "node:path";
6
6
  import pc from "picocolors";
7
7
  import { createServer } from "vite";
@@ -9,13 +9,15 @@ import { ViteNodeRunner } from "vite-node/client";
9
9
  import { ViteNodeServer } from "vite-node/server";
10
10
  import chokidar from "chokidar";
11
11
  import yamlToMomoa from "yaml-to-momoa";
12
+ import fs$1 from "node:fs/promises";
13
+ import { isAlias, pluralize } from "@terrazzo/token-tools";
14
+ import { merge } from "merge-anything";
15
+ import { camelCase } from "scule";
12
16
  import { spawn } from "node:child_process";
13
17
  import { confirm, intro, multiselect, outro, select, spinner } from "@clack/prompts";
14
- import { isAlias, pluralize } from "@terrazzo/token-tools";
15
18
  import { detect } from "detect-package-manager";
16
19
  import { generate } from "escodegen";
17
20
  import { parseModule } from "meriyah";
18
- import { readdir } from "node:fs/promises";
19
21
  import { Readable, Writable } from "node:stream";
20
22
  import { serve } from "@hono/node-server";
21
23
  import mime from "mime";
@@ -377,21 +379,584 @@ async function checkCmd({ config, logger, positionals }) {
377
379
  function helpCmd() {
378
380
  console.log(`tz
379
381
  [commands]
380
- build Build token artifacts from tokens.json
381
- --watch, -w Watch tokens.json for changes and recompile
382
- --no-lint Disable linters running on build
383
- check [path] Check tokens.json for errors and run linters
384
- lint [path] (alias of check)
385
- init Create a starter tokens.json file
386
- lab Manage your tokens with a web interface
382
+ build Build token artifacts from tokens.json
383
+ --watch, -w Watch tokens.json for changes and recompile
384
+ --no-lint Disable linters running on build
385
+ check [path] Check tokens.json for errors and run linters
386
+ lint [path] (alias of check)
387
+ init Create a starter tokens.json file
388
+ lab Manage your tokens with a web interface
389
+ import [path] Import from a Figma Design file
390
+ --o [file] Save imported JSON
391
+ --unpublished Include unpublished Variables
392
+ --skip-styles Don’t import styles
393
+ --skip-variables
394
+ Don’t import variables
387
395
 
388
396
  [options]
389
- --help Show this message
390
- --config, -c Path to config (default: ./terrazzo.config.ts)
391
- --quiet Suppress warnings
397
+ --help Show this message
398
+ --config, -c Path to config (default: ./terrazzo.config.ts)
399
+ --quiet Suppress warnings
392
400
  `);
393
401
  }
394
402
 
403
+ //#endregion
404
+ //#region src/import/figma/lib.ts
405
+ const KEY = ":key";
406
+ const FILE_KEY = ":file_key";
407
+ const API = {
408
+ file: `https://api.figma.com/v1/files/${FILE_KEY}`,
409
+ fileNodes: `https://api.figma.com/v1/files/${FILE_KEY}/nodes`,
410
+ fileStyles: `https://api.figma.com/v1/files/${FILE_KEY}/styles`,
411
+ localVariables: `https://api.figma.com/v1/files/${FILE_KEY}/variables/local`,
412
+ publishedVariables: `https://api.figma.com/v1/files/${FILE_KEY}/variables/published`,
413
+ styles: `https://api.figma.com/v1/styles/${KEY}`
414
+ };
415
+ /** Wrapper around camelCase to handle more cases */
416
+ function formatName(name) {
417
+ return camelCase(name.replace(/\s+/g, "-"));
418
+ }
419
+ const nf = new Intl.NumberFormat("en-us");
420
+ /** Wrapper around camelCase to handle more cases */
421
+ function formatNumber(number) {
422
+ return nf.format(number);
423
+ }
424
+ /** Get File ID from design URL */
425
+ function getFileID(url) {
426
+ return url.match(/^https:\/\/(www\.)?figma\.com\/design\/([^/]+)/)?.[2];
427
+ }
428
+ /** /v1/files/:file_key */
429
+ async function getFile(fileKey, { logger }) {
430
+ const res = await fetch(API.file.replace(FILE_KEY, fileKey), {
431
+ method: "GET",
432
+ headers: { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }
433
+ });
434
+ if (!res.ok) logger.error({
435
+ group: "import",
436
+ message: `${res.status} ${await res.text()}`
437
+ });
438
+ return await res.json();
439
+ }
440
+ /** /v1/files/:file_key/nodes */
441
+ async function getFileNodes(fileKey, { ids, logger }) {
442
+ let url = API.fileNodes.replace(FILE_KEY, fileKey);
443
+ if (ids?.length) url += `?ids=${ids.join(",")}`;
444
+ const res = await fetch(url, {
445
+ method: "GET",
446
+ headers: { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }
447
+ });
448
+ if (!res.ok) logger.error({
449
+ group: "import",
450
+ message: `${res.status} ${await res.text()}`
451
+ });
452
+ return await res.json();
453
+ }
454
+ /** /v1/files/:file_key/styles */
455
+ async function getFileStyles(fileKey, { logger }) {
456
+ const res = await fetch(API.fileStyles.replace(FILE_KEY, fileKey), {
457
+ method: "GET",
458
+ headers: { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }
459
+ });
460
+ if (!res.ok) logger.error({
461
+ group: "import",
462
+ message: `${res.status} ${await res.text()}`
463
+ });
464
+ return await res.json();
465
+ }
466
+ /** /v1/files/:file_key/variables/local */
467
+ async function getFileLocalVariables(fileKey, { logger }) {
468
+ const res = await fetch(API.localVariables.replace(FILE_KEY, fileKey), {
469
+ method: "GET",
470
+ headers: { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }
471
+ });
472
+ if (!res.ok) logger.error({
473
+ group: "import",
474
+ message: `${res.status} ${await res.text}`
475
+ });
476
+ return await res.json();
477
+ }
478
+ /** /v1/files/:file_key/variables/published */
479
+ async function getFilePublishedVariables(fileKey, { logger }) {
480
+ const res = await fetch(API.publishedVariables.replace(FILE_KEY, fileKey), {
481
+ method: "GET",
482
+ headers: { "X-Figma-Token": process.env.FIGMA_ACCESS_TOKEN }
483
+ });
484
+ if (!res.ok) logger.error({
485
+ group: "import",
486
+ message: `${res.status} ${await res.text}`
487
+ });
488
+ return await res.json();
489
+ }
490
+
491
+ //#endregion
492
+ //#region src/import/figma/styles.ts
493
+ /** /v1/files/:file_key/styles */
494
+ async function getStyles(fileKey, { logger, unpublished }) {
495
+ const result = {
496
+ count: 0,
497
+ code: { sets: { styles: { sources: [{}] } } }
498
+ };
499
+ const styleNodeIDs = /* @__PURE__ */ new Set();
500
+ const stylesByID = /* @__PURE__ */ new Map();
501
+ if (unpublished) {
502
+ const styles = await getFile(fileKey, { logger });
503
+ for (const [id, style] of Object.entries(styles.styles)) {
504
+ styleNodeIDs.add(id);
505
+ stylesByID.set(id, style);
506
+ }
507
+ } else {
508
+ const styles = await getFileStyles(fileKey, { logger });
509
+ for (const style of styles.meta.styles) {
510
+ styleNodeIDs.add(style.node_id);
511
+ stylesByID.set(style.node_id, style);
512
+ }
513
+ }
514
+ const fileNodes = await getFileNodes(fileKey, {
515
+ ids: [...styleNodeIDs],
516
+ logger
517
+ });
518
+ result.count += styleNodeIDs.size;
519
+ for (const [id, s] of stylesByID) {
520
+ const styleNode = fileNodes.nodes[id];
521
+ if (!styleNode) {
522
+ logger.warn({
523
+ group: "import",
524
+ message: `Style ${s.name} not found in file nodes. Does it need to be published?`
525
+ });
526
+ continue;
527
+ }
528
+ const styleType = "style_type" in s ? s.style_type : s.styleType;
529
+ const tokenBase = {
530
+ $type: void 0,
531
+ $description: s.description || void 0,
532
+ $value: void 0,
533
+ $extensions: { "figma.com": {
534
+ name: s.name,
535
+ node_id: id,
536
+ created_at: "created_at" in s ? s.created_at : void 0,
537
+ updated_at: "updated_at" in s ? s.updated_at : void 0
538
+ } }
539
+ };
540
+ switch (styleType) {
541
+ case "FILL": {
542
+ const $value = fillStyle(styleNode.document);
543
+ if (!$value) logger.error({
544
+ group: "import",
545
+ message: `Could not parse fill for ${s.name}`,
546
+ continueOnError: true
547
+ });
548
+ if (Array.isArray($value)) tokenBase.$type = "gradient";
549
+ else tokenBase.$type = "color";
550
+ tokenBase.$value = $value;
551
+ break;
552
+ }
553
+ case "TEXT": {
554
+ const $value = textStyle(styleNode.document);
555
+ if (!$value) logger.error({
556
+ group: "import",
557
+ message: `Could not parse text for ${s.name}`,
558
+ continueOnError: true
559
+ });
560
+ tokenBase.$type = "typography";
561
+ tokenBase.$value = $value;
562
+ break;
563
+ }
564
+ case "EFFECT": {
565
+ const $value = effectStyle(styleNode.document);
566
+ if (!$value) logger.error({
567
+ group: "import",
568
+ message: `Could not parse effect for ${s.name}`,
569
+ continueOnError: true
570
+ });
571
+ tokenBase.$type = "shadow";
572
+ tokenBase.$value = $value;
573
+ break;
574
+ }
575
+ case "GRID": {
576
+ const layoutGrids = gridStyles(styleNode.document);
577
+ if (!layoutGrids) logger.error({
578
+ group: "import",
579
+ message: `Could not parse grid for ${s.name}`,
580
+ continueOnError: true
581
+ });
582
+ let node = result.code.sets.styles.sources[0];
583
+ const path = s.name.split("/").map(formatName);
584
+ const name = path.pop();
585
+ for (const key of path) {
586
+ if (!(key in node)) node[key] = {};
587
+ node = node[key];
588
+ }
589
+ node[name] = layoutGrids;
590
+ break;
591
+ }
592
+ }
593
+ if (tokenBase.$type !== void 0) {
594
+ let node = result.code.sets.styles.sources[0];
595
+ const path = s.name.split("/").map(formatName);
596
+ const name = path.pop();
597
+ for (const key of path) {
598
+ if (!(key in node)) node[key] = {};
599
+ node = node[key];
600
+ }
601
+ node[name] = tokenBase;
602
+ }
603
+ }
604
+ return result;
605
+ }
606
+ /** Return a shadow token from an effect */
607
+ function effectStyle(node) {
608
+ if ("effects" in node) {
609
+ const shadows = node.effects.filter((e) => e.type === "DROP_SHADOW" || e.type === "INNER_SHADOW");
610
+ if (shadows.length) return shadows.map((s) => ({
611
+ inset: s.type === "INNER_SHADOW",
612
+ offsetX: {
613
+ value: s.offset.x,
614
+ unit: "px"
615
+ },
616
+ offsetY: {
617
+ value: s.offset.y,
618
+ unit: "px"
619
+ },
620
+ blur: {
621
+ value: s.radius,
622
+ unit: "px"
623
+ },
624
+ spread: {
625
+ value: s.spread ?? 0,
626
+ unit: "px"
627
+ },
628
+ color: {
629
+ colorSpace: "srgb",
630
+ components: [
631
+ s.color.r,
632
+ s.color.g,
633
+ s.color.b
634
+ ],
635
+ alpha: s.color.a
636
+ }
637
+ }));
638
+ }
639
+ }
640
+ /** Return a color or gradient token from a fill */
641
+ function fillStyle(node) {
642
+ if ("fills" in node) for (const fill of node.fills) switch (fill.type) {
643
+ case "SOLID": return {
644
+ colorSpace: "srgb",
645
+ components: [
646
+ fill.color.r,
647
+ fill.color.g,
648
+ fill.color.b
649
+ ],
650
+ alpha: fill.color.a
651
+ };
652
+ case "GRADIENT_LINEAR":
653
+ case "GRADIENT_RADIAL":
654
+ case "GRADIENT_ANGULAR":
655
+ case "GRADIENT_DIAMOND": return fill.gradientStops.map((stop) => ({
656
+ position: stop.position,
657
+ color: {
658
+ colorSpace: "srgb",
659
+ components: [
660
+ stop.color.r,
661
+ stop.color.g,
662
+ stop.color.b
663
+ ],
664
+ alpha: stop.color.a
665
+ }
666
+ }));
667
+ }
668
+ }
669
+ /** Return a dimension token from grid */
670
+ function gridStyles(node) {
671
+ if (!("layoutGrids" in node) || !node.layoutGrids?.length) return;
672
+ const values = {};
673
+ for (const grid of node.layoutGrids) {
674
+ const pattern = grid.pattern.toLowerCase();
675
+ if (values[pattern]) continue;
676
+ values[pattern] = {
677
+ sectionSize: {
678
+ $type: "dimension",
679
+ $value: {
680
+ value: grid.sectionSize,
681
+ unit: "px"
682
+ }
683
+ },
684
+ gutterSize: {
685
+ $type: "dimension",
686
+ $value: {
687
+ value: grid.sectionSize,
688
+ unit: "px"
689
+ }
690
+ }
691
+ };
692
+ if (grid.count > 0) values[pattern].count = {
693
+ $type: "number",
694
+ $value: grid.count
695
+ };
696
+ }
697
+ return values;
698
+ }
699
+ /** Return a typography token from text */
700
+ function textStyle(node) {
701
+ if (!("style" in node)) return;
702
+ return {
703
+ fontFamily: [node.style.fontFamily],
704
+ fontWeight: node.style.fontWeight,
705
+ fontStyle: node.style.fontStyle,
706
+ fontSize: node.style.fontSize ? {
707
+ value: node.style.fontSize,
708
+ unit: "px"
709
+ } : {
710
+ value: 1,
711
+ unit: "em"
712
+ },
713
+ letterSpacing: {
714
+ value: node.style.letterSpacing ?? 0,
715
+ unit: "px"
716
+ },
717
+ lineHeight: "lineHeightPercentFontSize" in node.style ? node.style.lineHeightPercentFontSize : "lineHeightPx" in node.style ? {
718
+ value: node.style.lineHeightPx,
719
+ unit: "px"
720
+ } : 1
721
+ };
722
+ }
723
+
724
+ //#endregion
725
+ //#region src/import/figma/variables.ts
726
+ /** /v1/files/:file_key/variables/published | /v1/files/:file_key/variables/local */
727
+ async function getVariables(fileKey, { logger, unpublished, matchers }) {
728
+ const result = {
729
+ count: 0,
730
+ remoteCount: 0,
731
+ code: {
732
+ sets: {},
733
+ modifiers: {}
734
+ }
735
+ };
736
+ const allVariables = {};
737
+ const variableCollections = {};
738
+ let finalVariables = {};
739
+ const modeIDToName = {};
740
+ const local = await getFileLocalVariables(fileKey, { logger });
741
+ for (const id of Object.keys(local.meta.variables)) {
742
+ if (local.meta.variables[id].hiddenFromPublishing) continue;
743
+ allVariables[id] = local.meta.variables[id];
744
+ }
745
+ for (const id of Object.keys(local.meta.variableCollections)) {
746
+ variableCollections[id] = local.meta.variableCollections[id];
747
+ for (const mode of local.meta.variableCollections[id].modes) modeIDToName[mode.modeId] = formatName(mode.name);
748
+ }
749
+ if (unpublished) finalVariables = allVariables;
750
+ else {
751
+ const published = await getFilePublishedVariables(fileKey, { logger });
752
+ for (const id of Object.keys(published.meta.variables)) finalVariables[id] = allVariables[id];
753
+ }
754
+ const remoteIDs = /* @__PURE__ */ new Set();
755
+ for (const id of Object.keys(finalVariables)) {
756
+ const variable = finalVariables[id];
757
+ const collection = variableCollections[variable.variableCollectionId];
758
+ const collectionName = formatName(collection.name);
759
+ const hasMultipleModes = collection.modes.length > 1;
760
+ if (hasMultipleModes) {
761
+ if (!(collectionName in result.code.modifiers)) result.code.modifiers[collectionName] = {
762
+ contexts: Object.fromEntries(collection.modes.map((m) => [formatName(m.name), [{}]])),
763
+ default: modeIDToName[collection.defaultModeId]
764
+ };
765
+ } else if (!(collectionName in result.code.sets)) result.code.sets[collectionName] = { sources: [{}] };
766
+ const matches = matchers.fontFamily?.test(variable.name) && "fontFamily" || matchers.fontWeight?.test(variable.name) && "fontWeight" || matchers.number?.test(variable.name) && "number" || void 0;
767
+ for (const [modeID, value] of Object.entries(variable.valuesByMode)) {
768
+ const modeName = modeIDToName[modeID];
769
+ let node = result.code;
770
+ if (hasMultipleModes) {
771
+ if (!(modeName in result.code.modifiers[collectionName].contexts)) result.code.modifiers[collectionName].contexts[modeName] = [{}];
772
+ node = result.code.modifiers[collectionName].contexts[modeName][0];
773
+ } else node = result.code.sets[collectionName].sources[0];
774
+ const tokenBase = {
775
+ $type: void 0,
776
+ $description: variable.description || void 0,
777
+ $value: void 0,
778
+ $extensions: { "figma.com": {
779
+ name: variable.name,
780
+ id: variable.id,
781
+ variableCollectionId: variable.variableCollectionId,
782
+ codeSyntax: Object.keys(variable.codeSyntax).length ? variable.codeSyntax : void 0
783
+ } }
784
+ };
785
+ const isAliasOfID = typeof value === "object" && "type" in value && value.type === "VARIABLE_ALIAS" && value.id || void 0;
786
+ if (isAliasOfID) if (allVariables[isAliasOfID]) {
787
+ tokenBase.$type = matches || {
788
+ COLOR: "color",
789
+ BOOLEAN: "boolean",
790
+ STRING: "string",
791
+ FLOAT: "dimension"
792
+ }[variable.resolvedType];
793
+ tokenBase.$value = `{${allVariables[isAliasOfID].name.split("/").map(formatName).join(".")}}`;
794
+ } else {
795
+ remoteIDs.add(isAliasOfID);
796
+ continue;
797
+ }
798
+ else if (matches === "fontFamily") {
799
+ tokenBase.$type = "fontFamily";
800
+ tokenBase.$value = String(value).split(",");
801
+ } else if (matches === "fontWeight") {
802
+ tokenBase.$type = "fontWeight";
803
+ tokenBase.$value = value;
804
+ } else if (matches === "number") {
805
+ if (typeof value === "object") throw new Error(`Can’t coerce ${variable.name} into number type.`);
806
+ tokenBase.$type = "number";
807
+ tokenBase.$value = Number(value);
808
+ } else switch (variable.resolvedType) {
809
+ case "BOOLEAN":
810
+ case "STRING":
811
+ tokenBase.$type = variable.resolvedType.toLowerCase();
812
+ tokenBase.$value = value;
813
+ break;
814
+ case "FLOAT":
815
+ tokenBase.$type = "dimension";
816
+ tokenBase.$value = {
817
+ value,
818
+ unit: "px"
819
+ };
820
+ break;
821
+ case "COLOR": {
822
+ const { r, g, b, a } = value;
823
+ tokenBase.$type = "color";
824
+ tokenBase.$value = {
825
+ colorSpace: "srgb",
826
+ components: [
827
+ r,
828
+ g,
829
+ b
830
+ ],
831
+ alpha: a
832
+ };
833
+ break;
834
+ }
835
+ }
836
+ if (tokenBase.$value !== void 0) {
837
+ const path = variable.name.split("/").map(formatName);
838
+ const name = path.pop();
839
+ for (const key of path) {
840
+ if (!(key in node)) node[key] = {};
841
+ node = node[key];
842
+ }
843
+ node[name] = tokenBase;
844
+ }
845
+ }
846
+ }
847
+ result.count = Object.keys(finalVariables).length;
848
+ result.remoteCount = remoteIDs.size;
849
+ return result;
850
+ }
851
+
852
+ //#endregion
853
+ //#region src/import/figma/index.ts
854
+ async function importFromFigma({ url, logger, unpublished, skipStyles, skipVariables, fontFamilyNames, fontWeightNames, numberNames }) {
855
+ const fileKey = getFileID(url);
856
+ if (!fileKey) logger.error({
857
+ group: "import",
858
+ message: `Invalid Figma URL: ${url}`
859
+ });
860
+ const result = {
861
+ variableCount: 0,
862
+ styleCount: 0,
863
+ code: {
864
+ $schema: "https://www.designtokens.org/schemas/2025.10/resolver.json",
865
+ version: "2025.10",
866
+ resolutionOrder: [],
867
+ sets: {},
868
+ modifiers: {}
869
+ }
870
+ };
871
+ try {
872
+ const [styles, vars] = await Promise.all([!skipStyles ? getStyles(fileKey, { logger }) : null, !skipVariables ? getVariables(fileKey, {
873
+ logger,
874
+ unpublished,
875
+ matchers: {
876
+ fontFamily: new RegExp(fontFamilyNames || "/fontFamily$"),
877
+ fontWeight: new RegExp(fontWeightNames || "/fontWeight$"),
878
+ number: numberNames ? new RegExp(numberNames) : void 0
879
+ }
880
+ }) : null]);
881
+ if (styles) {
882
+ result.styleCount += styles.count;
883
+ result.code = merge(result.code, styles.code);
884
+ }
885
+ if (vars) {
886
+ result.variableCount += vars.count;
887
+ result.code = merge(result.code, vars.code);
888
+ if (vars.remoteCount) logger.warn({
889
+ group: "import",
890
+ message: `${formatNumber(vars.remoteCount)} ${pluralize(vars.remoteCount, "Variable", "Variables")} were remote and could not be accessed. Try importing from other files to grab them.`
891
+ });
892
+ }
893
+ } catch (err) {
894
+ logger.error({
895
+ group: "import",
896
+ message: err.message
897
+ });
898
+ }
899
+ for (const group of ["sets", "modifiers"]) for (const name of Object.keys(result.code[group])) result.code.resolutionOrder.push({ $ref: `#/${group}/${name}` });
900
+ return result;
901
+ }
902
+ /** Is this a valid URL, and one belonging to a Figma file? */
903
+ function isFigmaPath(url) {
904
+ try {
905
+ new URL(url);
906
+ return /^https:\/\/(www\.)?figma\.com\/design\/[A-Za-z0-9]+/.test(url);
907
+ } catch {
908
+ return false;
909
+ }
910
+ }
911
+
912
+ //#endregion
913
+ //#region src/import/index.ts
914
+ async function importCmd({ flags, positionals, logger }) {
915
+ const [_cmd, url] = positionals;
916
+ if (!url) logger.error({
917
+ group: "import",
918
+ message: "Missing import path. Expected `tz import [file]`."
919
+ });
920
+ if (isFigmaPath(url)) {
921
+ const { FIGMA_ACCESS_TOKEN } = process.env;
922
+ if (!FIGMA_ACCESS_TOKEN) logger.error({
923
+ group: "import",
924
+ message: `FIGMA_ACCESS_TOKEN not set! See https://terrazzo.app/docs/guides/import-from-figma`
925
+ });
926
+ const start = performance.now();
927
+ const result = await importFromFigma({
928
+ url,
929
+ logger,
930
+ unpublished: flags.unpublished,
931
+ skipStyles: flags["skip-styles"],
932
+ skipVariables: flags["skip-variables"],
933
+ fontFamilyNames: flags["font-family-names"],
934
+ fontWeightNames: flags["font-weight-names"],
935
+ numberNames: flags["number-names"]
936
+ });
937
+ const end = performance.now() - start;
938
+ if (flags.output) {
939
+ const oldFile = fs.existsSync(flags.output) ? JSON.parse(await fs$1.readFile(flags.output, "utf8")) : {};
940
+ const code = {
941
+ $schema: result.code.$schema,
942
+ version: result.code.version,
943
+ resolutionOrder: oldFile.resolutionOrder?.length ? oldFile.resolutionOrder : result.code.resolutionOrder,
944
+ sets: result.code.sets,
945
+ modifiers: result.code.modifiers,
946
+ $defs: oldFile.$defs,
947
+ $extensions: oldFile.$extensions
948
+ };
949
+ await fs$1.writeFile(flags.output, `${JSON.stringify(code, void 0, 2)}\n`);
950
+ logger.info({
951
+ group: "import",
952
+ message: `Imported ${formatNumber(result.variableCount)} ${pluralize(result.variableCount, "Variable", "Variables")}, ${formatNumber(result.styleCount)} ${pluralize(result.styleCount, "Style", "Styles")} → ${flags.output}`,
953
+ timing: end
954
+ });
955
+ } else process.stdout.write(JSON.stringify(result.code));
956
+ return;
957
+ }
958
+ }
959
+
395
960
  //#endregion
396
961
  //#region src/init.ts
397
962
  const INSTALL_COMMAND = {
@@ -400,6 +965,12 @@ const INSTALL_COMMAND = {
400
965
  pnpm: "add -D --silent",
401
966
  bun: "install -D --silent"
402
967
  };
968
+ const SYNTAX_SETTINGS = { format: {
969
+ indent: { style: " " },
970
+ quotes: "single",
971
+ semicolons: true
972
+ } };
973
+ const EXAMPLE_TOKENS_PATH = "my-tokens.tokens.json";
403
974
  const DESIGN_SYSTEMS = {
404
975
  "adobe-spectrum": {
405
976
  name: "Spectrum",
@@ -441,19 +1012,19 @@ async function initCmd({ logger }) {
441
1012
  try {
442
1013
  intro("⛋ Welcome to Terrazzo");
443
1014
  const packageManager = await detect({ cwd: fileURLToPath(cwd) });
444
- const { config, configPath } = await loadConfig({
1015
+ const { config, configPath = "terrazzo.config.ts" } = await loadConfig({
445
1016
  cmd: "init",
446
1017
  flags: {},
447
1018
  logger
448
1019
  });
449
- const relConfigPath = configPath ? path.relative(fileURLToPath(cwd), fileURLToPath(new URL(configPath))) : void 0;
450
- let tokensPath = config.tokens[0];
1020
+ const tokensPath = config.tokens[0];
1021
+ const hasExistingConfig = fs.existsSync(configPath);
451
1022
  let startFromDS = !(tokensPath && fs.existsSync(tokensPath));
452
1023
  if (tokensPath && fs.existsSync(tokensPath)) {
453
1024
  if (await confirm({ message: `Found tokens at ${path.relative(fileURLToPath(cwd), fileURLToPath(tokensPath))}. Overwrite with a new design system?` })) startFromDS = true;
454
- } else tokensPath = DEFAULT_TOKENS_PATH;
1025
+ }
455
1026
  if (startFromDS) {
456
- if (DESIGN_SYSTEMS[await select({
1027
+ const ds = DESIGN_SYSTEMS[await select({
457
1028
  message: "Start from existing design system?",
458
1029
  options: [...Object.entries(DESIGN_SYSTEMS).map(([id, { author, name }]) => ({
459
1030
  value: id,
@@ -462,7 +1033,8 @@ async function initCmd({ logger }) {
462
1033
  value: "none",
463
1034
  label: "None"
464
1035
  }]
465
- })]) {
1036
+ })];
1037
+ if (ds) {
466
1038
  const s = spinner();
467
1039
  s.start("Downloading");
468
1040
  await new Promise((resolve, reject) => {
@@ -470,8 +1042,14 @@ async function initCmd({ logger }) {
470
1042
  subprocess.on("error", reject);
471
1043
  subprocess.on("exit", resolve);
472
1044
  });
473
- await s.stop("Download complete");
474
- }
1045
+ s.stop("Download complete");
1046
+ if (hasExistingConfig) await updateConfigTokens(configPath, ds.tokens);
1047
+ else await newConfigFile(configPath, ds.tokens);
1048
+ } else startFromDS = false;
1049
+ }
1050
+ if (!hasExistingConfig) {
1051
+ await newConfigFile(configPath, [EXAMPLE_TOKENS_PATH]);
1052
+ await fs$1.writeFile(EXAMPLE_TOKENS_PATH, JSON.stringify(EXAMPLE_TOKENS, void 0, 2));
475
1053
  }
476
1054
  const existingPlugins = config.plugins.map((p) => p.name);
477
1055
  const pluginSelection = await multiselect({
@@ -500,7 +1078,7 @@ async function initCmd({ logger }) {
500
1078
  if (newPlugins?.length) {
501
1079
  const plugins = newPlugins.map((p) => ({
502
1080
  specifier: p.replace("@terrazzo/plugin-", ""),
503
- package: p
1081
+ path: p
504
1082
  }));
505
1083
  const pluginCount = `${newPlugins.length} ${pluralize(newPlugins.length, "plugin", "plugins")}`;
506
1084
  const s = spinner();
@@ -511,77 +1089,27 @@ async function initCmd({ logger }) {
511
1089
  subprocess.on("exit", resolve);
512
1090
  });
513
1091
  s.message("Updating config");
514
- if (configPath) {
515
- const ast = parseModule(fs.readFileSync(configPath, "utf8"));
516
- const astExport = ast.body.find((node) => node.type === "ExportDefaultDeclaration");
517
- ast.body.push(...plugins.map((p) => ({
518
- type: "ImportDeclaration",
519
- source: {
520
- type: "Literal",
521
- value: p.package
522
- },
523
- specifiers: [{
524
- type: "ImportDefaultSpecifier",
525
- local: {
526
- type: "Identifier",
527
- name: p.specifier
528
- }
529
- }],
530
- attributes: []
531
- })));
532
- if (!astExport) {
533
- logger.error({
534
- group: "config",
535
- message: `SyntaxError: ${relConfigPath} does not have default export.`
536
- });
537
- return;
538
- }
539
- const astConfig = astExport.declaration.type === "CallExpression" ? astExport.declaration.arguments[0] : astExport.declaration;
540
- if (astConfig.type !== "ObjectExpression") {
541
- logger.error({
542
- group: "config",
543
- message: `Config: expected object default export, received ${astConfig.type}`
544
- });
545
- return;
546
- }
547
- const pluginsArray = astConfig.properties.find((property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "plugins")?.value;
548
- const pluginsAst = plugins.map((p) => ({
549
- type: "CallExpression",
550
- callee: {
551
- type: "Identifier",
552
- name: p.specifier
553
- },
554
- arguments: [],
555
- optional: false
556
- }));
557
- if (pluginsArray) pluginsArray.elements.push(...pluginsAst);
558
- else astConfig.properties.push({
559
- type: "Property",
560
- key: {
561
- type: "Identifier",
562
- name: "plugins"
563
- },
564
- value: {
565
- type: "ArrayExpression",
566
- elements: pluginsAst
567
- },
568
- kind: "init",
569
- computed: false,
570
- method: false,
571
- shorthand: false
572
- });
573
- fs.writeFileSync(configPath, generate(ast, { format: {
574
- indent: { style: " " },
575
- quotes: "single",
576
- semicolons: true
577
- } }));
578
- } else fs.writeFileSync(DEFAULT_CONFIG_PATH, `import { defineConfig } from '@terrazzo/cli';
579
- ${plugins.map((p) => `import ${p.specifier} from '${p.package}';`).join("\n")}
1092
+ await updateConfigPlugins(configPath, plugins);
1093
+ s.stop(`Installed ${pluginCount}`);
1094
+ }
1095
+ if (!startFromDS && !newPlugins?.length) {
1096
+ outro("Nothing to do. Exiting.");
1097
+ return;
1098
+ }
1099
+ outro("⛋ Done! 🎉");
1100
+ } catch (err) {
1101
+ printError(err.message);
1102
+ process.exit(1);
1103
+ }
1104
+ }
1105
+ async function newConfigFile(configPath, tokens, imports = []) {
1106
+ await fs$1.writeFile(configPath, `import { defineConfig } from '@terrazzo/cli';
1107
+ ${imports.map((p) => `import ${p.specifier} from '${p.path}';`).join("\n")}
580
1108
 
581
1109
  export default defineConfig({
582
- tokens: ['./tokens.json'],
1110
+ tokens: ['${tokens.join("', '")}'],
583
1111
  plugins: [
584
- ${plugins.map((p) => `${p.specifier}(),`).join("\n ")}
1112
+ ${imports.length ? imports.map((p) => `${p.specifier}(),`).join("\n ") : "/** @see https://terrazzo.app/docs */"}
585
1113
  ],
586
1114
  outDir: './dist/',
587
1115
  lint: {
@@ -610,14 +1138,279 @@ export default defineConfig({
610
1138
  },
611
1139
  },
612
1140
  });`);
613
- s.stop(`Installed ${pluginCount}`);
614
- }
615
- outro("⛋ Done! 🎉");
616
- } catch (err) {
617
- printError(err.message);
618
- process.exit(1);
1141
+ }
1142
+ function getConfigObjFromAst(ast, configPath) {
1143
+ const astExport = ast.body.find((node) => node.type === "ExportDefaultDeclaration");
1144
+ if (!astExport) {
1145
+ const relConfigPath = configPath ? path.relative(fileURLToPath(cwd), fileURLToPath(new URL(configPath))) : void 0;
1146
+ throw new Error(`SyntaxError: ${relConfigPath} does not have default export.`);
619
1147
  }
1148
+ const astConfig = astExport.declaration.type === "CallExpression" ? astExport.declaration.arguments[0] : astExport.declaration;
1149
+ if (astConfig.type !== "ObjectExpression") throw new Error(`Config: expected object default export, received ${astConfig.type}.`);
1150
+ return astConfig;
1151
+ }
1152
+ async function updateConfigTokens(configPath, tokens) {
1153
+ const ast = parseModule(await fs$1.readFile(configPath, "utf8"));
1154
+ const astConfig = getConfigObjFromAst(ast, configPath);
1155
+ let tokensKey = astConfig.properties.find((p) => p.type === "Property" && p.key.type === "Identifier" && p.key.name === "tokens");
1156
+ if (!tokensKey) {
1157
+ tokensKey = {
1158
+ type: "Property",
1159
+ key: {
1160
+ type: "Identifier",
1161
+ name: "tokens"
1162
+ },
1163
+ method: false,
1164
+ computed: false,
1165
+ shorthand: false,
1166
+ value: {
1167
+ type: "ArrayExpression",
1168
+ elements: tokens.map((value) => ({
1169
+ type: "Literal",
1170
+ value,
1171
+ raw: `'${value}'`
1172
+ }))
1173
+ }
1174
+ };
1175
+ astConfig.properties.unshift(tokensKey);
1176
+ }
1177
+ await fs$1.writeFile(configPath, generate(ast, SYNTAX_SETTINGS));
1178
+ }
1179
+ /**
1180
+ * Add plugin imports
1181
+ * note: this has the potential to duplicate plugins, but we tried our
1182
+ * best to filter already, and this may be the user’s fault if they
1183
+ * selected to install a plugin already installed. But also, this is
1184
+ * easily-fixable, so let’s not waste too much time here (and possibly
1185
+ * introduce bugs).
1186
+ */
1187
+ async function updateConfigPlugins(configPath, plugins) {
1188
+ const ast = parseModule(await fs$1.readFile(configPath, "utf8"));
1189
+ ast.body.push(...plugins.map((p) => ({
1190
+ type: "ImportDeclaration",
1191
+ source: {
1192
+ type: "Literal",
1193
+ value: p.path
1194
+ },
1195
+ specifiers: [{
1196
+ type: "ImportDefaultSpecifier",
1197
+ local: {
1198
+ type: "Identifier",
1199
+ name: p.specifier
1200
+ }
1201
+ }],
1202
+ attributes: []
1203
+ })));
1204
+ const astConfig = getConfigObjFromAst(ast, configPath);
1205
+ const pluginsArray = astConfig.properties.find((property) => property.type === "Property" && property.key.type === "Identifier" && property.key.name === "plugins")?.value;
1206
+ const pluginsAst = plugins.map((p) => ({
1207
+ type: "CallExpression",
1208
+ callee: {
1209
+ type: "Identifier",
1210
+ name: p.specifier
1211
+ },
1212
+ arguments: [],
1213
+ optional: false
1214
+ }));
1215
+ if (pluginsArray) pluginsArray.elements.push(...pluginsAst);
1216
+ else astConfig.properties.push({
1217
+ type: "Property",
1218
+ key: {
1219
+ type: "Identifier",
1220
+ name: "plugins"
1221
+ },
1222
+ value: {
1223
+ type: "ArrayExpression",
1224
+ elements: pluginsAst
1225
+ },
1226
+ kind: "init",
1227
+ computed: false,
1228
+ method: false,
1229
+ shorthand: false
1230
+ });
1231
+ await fs$1.writeFile(configPath, generate(ast, SYNTAX_SETTINGS));
620
1232
  }
1233
+ const EXAMPLE_TOKENS = {
1234
+ color: {
1235
+ $description: "Color tokens",
1236
+ black: {
1237
+ "100": {
1238
+ $type: "color",
1239
+ $value: {
1240
+ colorSpace: "srgb",
1241
+ components: [
1242
+ .047,
1243
+ .047,
1244
+ .047
1245
+ ],
1246
+ alpha: .05,
1247
+ hex: "#0c0c0d"
1248
+ }
1249
+ },
1250
+ "200": {
1251
+ $type: "color",
1252
+ $value: {
1253
+ colorSpace: "srgb",
1254
+ components: [
1255
+ .047,
1256
+ .047,
1257
+ .047
1258
+ ],
1259
+ alpha: .1,
1260
+ hex: "#0c0c0d"
1261
+ }
1262
+ },
1263
+ "300": {
1264
+ $type: "color",
1265
+ $value: {
1266
+ colorSpace: "srgb",
1267
+ components: [
1268
+ .047,
1269
+ .047,
1270
+ .047
1271
+ ],
1272
+ alpha: .2,
1273
+ hex: "#0c0c0d"
1274
+ }
1275
+ },
1276
+ "400": {
1277
+ $type: "color",
1278
+ $value: {
1279
+ colorSpace: "srgb",
1280
+ components: [
1281
+ .047,
1282
+ .047,
1283
+ .047
1284
+ ],
1285
+ alpha: .34,
1286
+ hex: "#0c0c04"
1287
+ }
1288
+ },
1289
+ "500": {
1290
+ $type: "color",
1291
+ $value: {
1292
+ colorSpace: "srgb",
1293
+ components: [
1294
+ .047,
1295
+ .047,
1296
+ .047
1297
+ ],
1298
+ alpha: .7,
1299
+ hex: "#0c0c0d"
1300
+ }
1301
+ },
1302
+ "600": {
1303
+ $type: "color",
1304
+ $value: {
1305
+ colorSpace: "srgb",
1306
+ components: [
1307
+ .047,
1308
+ .047,
1309
+ .047
1310
+ ],
1311
+ alpha: .8,
1312
+ hex: "#0c0c0d"
1313
+ }
1314
+ },
1315
+ "700": {
1316
+ $type: "color",
1317
+ $value: {
1318
+ colorSpace: "srgb",
1319
+ components: [
1320
+ .047,
1321
+ .047,
1322
+ .047
1323
+ ],
1324
+ alpha: .85,
1325
+ hex: "#0c0c0d"
1326
+ }
1327
+ },
1328
+ "800": {
1329
+ $type: "color",
1330
+ $value: {
1331
+ colorSpace: "srgb",
1332
+ components: [
1333
+ .047,
1334
+ .047,
1335
+ .047
1336
+ ],
1337
+ alpha: .9,
1338
+ hex: "#0c0c0d"
1339
+ }
1340
+ },
1341
+ "900": {
1342
+ $type: "color",
1343
+ $value: {
1344
+ colorSpace: "srgb",
1345
+ components: [
1346
+ .047,
1347
+ .047,
1348
+ .047
1349
+ ],
1350
+ alpha: .95,
1351
+ hex: "#0c0c0d"
1352
+ }
1353
+ },
1354
+ "1000": {
1355
+ $type: "color",
1356
+ $value: {
1357
+ colorSpace: "srgb",
1358
+ components: [
1359
+ .047,
1360
+ .047,
1361
+ .047
1362
+ ],
1363
+ alpha: 0,
1364
+ hex: "#0c0c0d"
1365
+ }
1366
+ }
1367
+ }
1368
+ },
1369
+ border: {
1370
+ $description: "Border tokens",
1371
+ default: {
1372
+ type: "border",
1373
+ $value: {
1374
+ color: "{color.black.900}",
1375
+ style: "solid",
1376
+ width: {
1377
+ value: 1,
1378
+ unit: "px"
1379
+ }
1380
+ }
1381
+ }
1382
+ },
1383
+ radius: {
1384
+ $description: "Corner radius tokens",
1385
+ "100": { $value: {
1386
+ value: .25,
1387
+ unit: "rem"
1388
+ } }
1389
+ },
1390
+ space: {
1391
+ $description: "Dimension tokens",
1392
+ "100": { $value: {
1393
+ value: .25,
1394
+ unit: "rem"
1395
+ } }
1396
+ },
1397
+ typography: {
1398
+ $description: "Typography tokens",
1399
+ body: {
1400
+ $type: "typography",
1401
+ $value: {
1402
+ fontFamily: "{typography.family.sans}",
1403
+ fontSize: "{typography.scale.03}",
1404
+ fontWeight: "{typography.weight.regular}",
1405
+ letterSpacing: {
1406
+ value: 0,
1407
+ unit: "em"
1408
+ },
1409
+ lineHeight: 1
1410
+ }
1411
+ }
1412
+ }
1413
+ };
621
1414
 
622
1415
  //#endregion
623
1416
  //#region src/lab.ts
@@ -625,7 +1418,7 @@ async function labCmd({ config, logger }) {
625
1418
  /** TODO: handle multiple files */
626
1419
  const [tokenFileUrl] = config.tokens;
627
1420
  const staticFiles = /* @__PURE__ */ new Set();
628
- const dirEntries = await readdir(fileURLToPath(import.meta.resolve("./lab")), {
1421
+ const dirEntries = await fs$1.readdir(fileURLToPath(import.meta.resolve("./lab")), {
629
1422
  withFileTypes: true,
630
1423
  recursive: true
631
1424
  });
@@ -639,18 +1432,18 @@ async function labCmd({ config, logger }) {
639
1432
  overrideGlobalObjects: false,
640
1433
  async fetch(request) {
641
1434
  const pathname = new URL(request.url).pathname;
642
- if (pathname === "/") return new Response(Readable.toWeb(createReadStream(fileURLToPath(import.meta.resolve("./lab/index.html")))), { headers: { "Content-Type": "text/html" } });
1435
+ if (pathname === "/") return new Response(Readable.toWeb(fs.createReadStream(fileURLToPath(import.meta.resolve("./lab/index.html")))), { headers: { "Content-Type": "text/html" } });
643
1436
  if (pathname === "/api/tokens") {
644
- if (request.method === "GET") return new Response(Readable.toWeb(createReadStream(tokenFileUrl)), { headers: {
1437
+ if (request.method === "GET") return new Response(Readable.toWeb(fs.createReadStream(tokenFileUrl)), { headers: {
645
1438
  "Content-Type": "application/json",
646
1439
  "Cache-Control": "no-cache"
647
1440
  } });
648
1441
  else if (request.method === "POST" && request.body) {
649
- await request.body.pipeTo(Writable.toWeb(createWriteStream(tokenFileUrl)));
1442
+ await request.body.pipeTo(Writable.toWeb(fs.createWriteStream(tokenFileUrl)));
650
1443
  return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json" } });
651
1444
  }
652
1445
  }
653
- if (staticFiles.has(pathname)) return new Response(Readable.toWeb(createReadStream(fileURLToPath(import.meta.resolve(`./lab${pathname}`)))), { headers: { "Content-Type": mime.getType(pathname) ?? "application/octet-stream" } });
1446
+ if (staticFiles.has(pathname)) return new Response(Readable.toWeb(fs.createReadStream(fileURLToPath(import.meta.resolve(`./lab${pathname}`)))), { headers: { "Content-Type": mime.getType(pathname) ?? "application/octet-stream" } });
654
1447
  return new Response("Not found", { status: 404 });
655
1448
  }
656
1449
  }, (info) => {
@@ -807,5 +1600,5 @@ function defineConfig(config) {
807
1600
  }
808
1601
 
809
1602
  //#endregion
810
- export { DEFAULT_CONFIG_PATH, DEFAULT_TOKENS_PATH, GREEN_CHECK, buildCmd, checkCmd, cwd, defineConfig, helpCmd, initCmd, labCmd, loadConfig, loadTokens, normalizeCmd, printError, printSuccess, resolveConfig, resolveTokenPath, time, versionCmd, writeFiles };
1603
+ export { DEFAULT_CONFIG_PATH, DEFAULT_TOKENS_PATH, GREEN_CHECK, buildCmd, checkCmd, cwd, defineConfig, helpCmd, importCmd, importFromFigma, initCmd, isFigmaPath, labCmd, loadConfig, loadTokens, normalizeCmd, printError, printSuccess, resolveConfig, resolveTokenPath, time, versionCmd, writeFiles };
811
1604
  //# sourceMappingURL=index.js.map