spec-layer 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +99 -21
  2. package/dist/cli.js +1575 -57
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -559,7 +559,7 @@ var require_sha256 = __commonJS({
559
559
  import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
- import { join as join3 } from "node:path";
562
+ import { join as join5 } from "node:path";
563
563
 
564
564
  // ../extractor/src/statesMatrix.ts
565
565
  var STATE_ORDER = [
@@ -588,6 +588,71 @@ var STATE_ORDER = [
588
588
  ];
589
589
  var STATE_VOCAB = new Set(STATE_ORDER);
590
590
 
591
+ // ../extractor/src/v5/precision.ts
592
+ var SIGNIFICANT_DIGITS = 7;
593
+ var FLOAT32_EXACT_LIMIT = 16777216;
594
+ function canonicalNumber(n) {
595
+ if (!Number.isFinite(n)) return n;
596
+ if (Number.isInteger(n)) return n + 0;
597
+ if (Math.abs(n) >= FLOAT32_EXACT_LIMIT) return n + 0;
598
+ return Number(n.toPrecision(SIGNIFICANT_DIGITS)) + 0;
599
+ }
600
+
601
+ // ../extractor/src/v5/diagnostics.ts
602
+ var DEFAULT_SEVERITY = {
603
+ UNRESOLVED_ALIAS: "error",
604
+ UNRESOLVED_EXTERNAL_ALIAS: "error",
605
+ ALIAS_CYCLE: "error",
606
+ ALIAS_TYPE_MISMATCH: "error",
607
+ MISSING_MODE_VALUE: "error",
608
+ DUPLICATE_SOURCE_ID: "error",
609
+ PATH_COLLISION: "error",
610
+ UNSUPPORTED_VALUE_TYPE: "error",
611
+ INCONSISTENT_VALUE_SHAPE: "error",
612
+ AMBIGUOUS_ALIAS_TARGET: "error",
613
+ INVALID_SOURCE_COLOR: "error",
614
+ // Error, and the same severity UNRESOLVED_ALIAS carries: §18 Level 2 makes
615
+ // no distinction between reference classes, and an artifact whose token
616
+ // points at a collection that is not there is broken in exactly the way a
617
+ // dangling alias is -- a consumer joining on the id gets nothing.
618
+ UNRESOLVED_REFERENCE: "error",
619
+ SOURCE_PARTIALLY_UNAVAILABLE: "error",
620
+ STYLE_BINDING_DRIFT: "warning",
621
+ CONFUSABLE_NAME: "warning",
622
+ INFERRED_LIFECYCLE: "warning",
623
+ DEPRECATED_REFERENCE: "warning",
624
+ GENERATED_NAME_COLLISION: "warning",
625
+ // Warning, not error: a migrated artifact with synthetic ids is still usable
626
+ // for generation -- what it cannot do is survive a rename, which is a fact
627
+ // about future diffs rather than about this artifact's correctness.
628
+ SYNTHETIC_IDENTITY: "warning",
629
+ // Warning, not error: the number is retained and usable, and Level 4
630
+ // readiness is a consumer's judgment. What the consumer must decide is the
631
+ // unit; the message says so and `units` overrides in the CLI are the
632
+ // remedy.
633
+ UNIT_METADATA_UNAVAILABLE: "warning",
634
+ MODE_VALUES_IDENTICAL: "info",
635
+ MISSING_DESCRIPTION: "info",
636
+ // Info, not error: no value depends on metadata the Plugin API never
637
+ // exposes in the first place -- there is nothing for a consumer to decide
638
+ // and nothing missing that this export could have captured.
639
+ METADATA_UNAVAILABLE: "info",
640
+ // Info, not error: a deliberately scoped export states its own scope in
641
+ // `completeness`; that is a fact about the request, not a defect in it.
642
+ EXPORT_SCOPED: "info"
643
+ };
644
+ var compareCodeUnits = (a, b) => a < b ? -1 : a > b ? 1 : 0;
645
+ function diagnostic(code, fields) {
646
+ return {
647
+ code,
648
+ severity: DEFAULT_SEVERITY[code],
649
+ entity_id: fields.entity_id,
650
+ ...fields.mode_id !== void 0 ? { mode_id: fields.mode_id } : {},
651
+ message: fields.message,
652
+ ...fields.details !== void 0 ? { details: fields.details } : {}
653
+ };
654
+ }
655
+
591
656
  // ../extractor/src/hash.ts
592
657
  var import_js_sha256 = __toESM(require_sha256(), 1);
593
658
 
@@ -733,9 +798,1241 @@ var FOUNDATION_SYSTEM_PROMPT = [
733
798
  "No prose outside the JSON, no code fence."
734
799
  ].join("\n");
735
800
 
801
+ // ../extractor/src/v5/value.ts
802
+ var SUPPORTED_UNITS = ["px", "rem", "em", "%", "deg", "ms", "s"];
803
+ var SUPPORTED_TOKEN_TYPES = [
804
+ "color",
805
+ "dimension",
806
+ "number",
807
+ "string",
808
+ "boolean",
809
+ "duration",
810
+ "cubic_bezier",
811
+ "font_family"
812
+ ];
813
+ var SUPPORTED_VALUE_KINDS = ["literal", "alias", "missing"];
814
+ var SUPPORTED_DURATION_UNITS = ["ms", "s"];
815
+
736
816
  // ../extractor/src/v5/canonical.ts
737
817
  var import_js_sha2562 = __toESM(require_sha256(), 1);
738
818
 
819
+ // ../extractor/src/v5/validate.ts
820
+ var ROOT = "<artifact>";
821
+ var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
822
+ var isNonEmptyString = (v) => typeof v === "string" && v.length > 0;
823
+ var isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v);
824
+ var isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === "string");
825
+ var HEX_RE = /^#[0-9a-f]{6}$/;
826
+ function shape(entityId, message, modeId) {
827
+ return diagnostic("INCONSISTENT_VALUE_SHAPE", {
828
+ entity_id: entityId,
829
+ message,
830
+ ...modeId !== void 0 ? { mode_id: modeId } : {}
831
+ });
832
+ }
833
+ function unsupported(entityId, message, modeId) {
834
+ return diagnostic("UNSUPPORTED_VALUE_TYPE", {
835
+ entity_id: entityId,
836
+ message,
837
+ ...modeId !== void 0 ? { mode_id: modeId } : {}
838
+ });
839
+ }
840
+ function validateTypedValue(type, tv, entityId, out, modeId) {
841
+ switch (type) {
842
+ case "color": {
843
+ if (tv.color_space !== "srgb") {
844
+ out.push(unsupported(entityId, 'color.color_space must be "srgb".', modeId));
845
+ }
846
+ if (typeof tv.hex !== "string" || !HEX_RE.test(tv.hex)) {
847
+ out.push(unsupported(entityId, 'color.hex must be six lowercase hex digits with a leading "#".', modeId));
848
+ }
849
+ if (!isFiniteNumber(tv.alpha) || tv.alpha < 0 || tv.alpha > 1) {
850
+ out.push(unsupported(entityId, "color.alpha must be a finite number between 0 and 1.", modeId));
851
+ }
852
+ if (tv.channels !== void 0) {
853
+ const channels = tv.channels;
854
+ if (!Array.isArray(channels) || channels.length !== 3 || !channels.every(isFiniteNumber)) {
855
+ out.push(unsupported(entityId, "color.channels must be three finite numbers when present.", modeId));
856
+ }
857
+ }
858
+ break;
859
+ }
860
+ case "dimension": {
861
+ if (!isFiniteNumber(tv.number)) {
862
+ out.push(unsupported(entityId, "dimension.number must be a finite number.", modeId));
863
+ }
864
+ if (typeof tv.unit !== "string" || !SUPPORTED_UNITS.includes(tv.unit)) {
865
+ out.push(unsupported(entityId, "dimension.unit is missing or outside the supported unit vocabulary.", modeId));
866
+ }
867
+ break;
868
+ }
869
+ case "number": {
870
+ if (!isFiniteNumber(tv.value)) {
871
+ out.push(unsupported(entityId, "number.value must be a finite number.", modeId));
872
+ }
873
+ break;
874
+ }
875
+ case "string": {
876
+ if (typeof tv.value !== "string") {
877
+ out.push(unsupported(entityId, "string.value must be a string.", modeId));
878
+ }
879
+ break;
880
+ }
881
+ case "boolean": {
882
+ if (typeof tv.value !== "boolean") {
883
+ out.push(unsupported(entityId, "boolean.value must be a boolean.", modeId));
884
+ }
885
+ break;
886
+ }
887
+ case "duration": {
888
+ if (!isFiniteNumber(tv.number)) {
889
+ out.push(unsupported(entityId, "duration.number must be a finite number.", modeId));
890
+ }
891
+ if (typeof tv.unit !== "string" || !SUPPORTED_DURATION_UNITS.includes(tv.unit)) {
892
+ out.push(unsupported(entityId, 'duration.unit must be "ms" or "s".', modeId));
893
+ }
894
+ break;
895
+ }
896
+ case "cubic_bezier": {
897
+ const value = tv.value;
898
+ if (!Array.isArray(value) || value.length !== 4 || !value.every(isFiniteNumber)) {
899
+ out.push(unsupported(entityId, "cubic_bezier.value must be four finite numbers.", modeId));
900
+ }
901
+ break;
902
+ }
903
+ case "font_family": {
904
+ if (typeof tv.value !== "string") {
905
+ out.push(unsupported(entityId, "font_family.value must be a string.", modeId));
906
+ }
907
+ break;
908
+ }
909
+ default:
910
+ break;
911
+ }
912
+ }
913
+ function validateTypedValueEnvelope(value, entityId, out, modeId, expectedType) {
914
+ if (!isRecord(value)) {
915
+ out.push(shape(entityId, "A typed value must be an object.", modeId));
916
+ return;
917
+ }
918
+ const { type } = value;
919
+ if (typeof type !== "string" || !SUPPORTED_TOKEN_TYPES.includes(type)) {
920
+ out.push(shape(entityId, `Typed value has an unrecognized or missing "type" discriminant: ${JSON.stringify(type)}.`, modeId));
921
+ return;
922
+ }
923
+ if (expectedType !== void 0 && type !== expectedType) {
924
+ out.push(shape(
925
+ entityId,
926
+ `Expected a "${expectedType}" typed value, but the value declares "${type}".`,
927
+ modeId
928
+ ));
929
+ }
930
+ validateTypedValue(type, value, entityId, out, modeId);
931
+ }
932
+ function validateChainStep(step, entityId, out, modeId) {
933
+ if (!isRecord(step) || typeof step.token_id !== "string" || typeof step.mode_id !== "string") {
934
+ out.push(shape(entityId, "A resolution chain step must carry both token_id and mode_id.", modeId));
935
+ }
936
+ }
937
+ function validateAliasReference(ref, entityId, out, modeId) {
938
+ if (!(ref.target_id === null || typeof ref.target_id === "string")) {
939
+ out.push(shape(entityId, "alias.reference.target_id must be a string or null.", modeId));
940
+ }
941
+ if (!(ref.target_collection_id === null || typeof ref.target_collection_id === "string")) {
942
+ out.push(shape(entityId, "alias.reference.target_collection_id must be a string or null.", modeId));
943
+ }
944
+ if (!isStringArray(ref.target_path)) {
945
+ out.push(shape(entityId, "alias.reference.target_path must be an array of strings.", modeId));
946
+ }
947
+ if (typeof ref.external !== "boolean") {
948
+ out.push(shape(entityId, "alias.reference.external must be a boolean.", modeId));
949
+ }
950
+ if (ref.source_library_name !== void 0 && typeof ref.source_library_name !== "string") {
951
+ out.push(shape(entityId, "alias.reference.source_library_name must be a string when present.", modeId));
952
+ }
953
+ }
954
+ function validateAliasResolution(resolved2, entityId, out, modeId, expectedType) {
955
+ const { status } = resolved2;
956
+ if (status === "resolved") {
957
+ validateTypedValueEnvelope(resolved2.value, entityId, out, modeId, expectedType);
958
+ } else if (status === "unresolved") {
959
+ if (!isNonEmptyString(resolved2.reason)) {
960
+ out.push(shape(entityId, "An unresolved alias must carry a non-empty reason.", modeId));
961
+ }
962
+ if (resolved2.value !== null) {
963
+ out.push(shape(entityId, "An unresolved alias must carry a null value.", modeId));
964
+ }
965
+ } else {
966
+ out.push(shape(entityId, `alias.resolved.status has an unrecognized value: ${JSON.stringify(status)}.`, modeId));
967
+ }
968
+ if (!Array.isArray(resolved2.chain)) {
969
+ out.push(shape(entityId, "alias.resolved.chain must be an array.", modeId));
970
+ } else {
971
+ for (const step of resolved2.chain) validateChainStep(step, entityId, out, modeId);
972
+ }
973
+ }
974
+ function validateValue(value, entityId, modeId, out, expectedType) {
975
+ if (!isRecord(value)) {
976
+ out.push(shape(entityId, "A token value must be an object, not a bare primitive.", modeId));
977
+ return;
978
+ }
979
+ const { kind } = value;
980
+ if (typeof kind !== "string" || !SUPPORTED_VALUE_KINDS.includes(kind)) {
981
+ out.push(shape(entityId, `Value has an unrecognized or missing "kind" discriminant: ${JSON.stringify(kind)}.`, modeId));
982
+ return;
983
+ }
984
+ if (kind === "literal") {
985
+ validateTypedValueEnvelope(value.value, entityId, out, modeId, expectedType);
986
+ } else if (kind === "alias") {
987
+ if (!isRecord(value.reference)) {
988
+ out.push(shape(entityId, "An alias value must carry a reference object.", modeId));
989
+ } else {
990
+ validateAliasReference(value.reference, entityId, out, modeId);
991
+ }
992
+ if (!isRecord(value.resolved)) {
993
+ out.push(shape(entityId, "An alias value must carry a resolved object.", modeId));
994
+ } else {
995
+ validateAliasResolution(value.resolved, entityId, out, modeId, expectedType);
996
+ }
997
+ } else if (kind === "missing") {
998
+ if (!isNonEmptyString(value.reason)) {
999
+ out.push(shape(entityId, "A missing value must carry a non-empty reason.", modeId));
1000
+ }
1001
+ }
1002
+ }
1003
+ function validateIdentity(entity, entityId, kind, out) {
1004
+ if (!isNonEmptyString(entity.id)) {
1005
+ out.push(shape(entityId, `${kind}.id must be a non-empty string.`));
1006
+ }
1007
+ if (typeof entity.name !== "string") {
1008
+ out.push(shape(entityId, `${kind}.name must be a string.`));
1009
+ }
1010
+ if (!isStringArray(entity.path) || entity.path.length === 0) {
1011
+ out.push(shape(entityId, `${kind}.path must be a non-empty array of strings.`));
1012
+ }
1013
+ if (entity.suggested_code_name !== void 0 && typeof entity.suggested_code_name !== "string") {
1014
+ out.push(shape(entityId, `${kind}.suggested_code_name must be a string when present.`));
1015
+ }
1016
+ }
1017
+ function validatePublication(value, entityId, out) {
1018
+ if (!isRecord(value) || typeof value.published !== "boolean" || typeof value.hidden_from_publishing !== "boolean") {
1019
+ out.push(shape(
1020
+ entityId,
1021
+ "publication must state boolean published and hidden_from_publishing fields."
1022
+ ));
1023
+ }
1024
+ }
1025
+ function validateLifecycle(value, entityId, out) {
1026
+ if (!isRecord(value)) {
1027
+ out.push(shape(entityId, "lifecycle must be an object when present."));
1028
+ return;
1029
+ }
1030
+ if (!["active", "deprecated", "archived"].includes(value.status)) {
1031
+ out.push(shape(entityId, "lifecycle.status must be active, deprecated, or archived."));
1032
+ }
1033
+ if (!(value.replacement_id === null || typeof value.replacement_id === "string")) {
1034
+ out.push(shape(entityId, "lifecycle.replacement_id must be a string or null."));
1035
+ }
1036
+ }
1037
+ function validateSource(value, entityId, out) {
1038
+ if (!isRecord(value)) {
1039
+ out.push(shape(entityId, "source must be an object when present."));
1040
+ return;
1041
+ }
1042
+ if (typeof value.remote !== "boolean") {
1043
+ out.push(shape(entityId, "source.remote must be a boolean."));
1044
+ }
1045
+ for (const field of ["library_file_id", "library_name", "modified_at"]) {
1046
+ if (!(value[field] === null || typeof value[field] === "string")) {
1047
+ out.push(shape(entityId, `source.${field} must be a string or null.`));
1048
+ }
1049
+ }
1050
+ }
1051
+ function validateOptionalEntityMetadata(entity, entityId, out, includeSource = false, includeLifecycle = true) {
1052
+ if (entity.publication !== void 0) validatePublication(entity.publication, entityId, out);
1053
+ if (includeLifecycle && entity.lifecycle !== void 0) validateLifecycle(entity.lifecycle, entityId, out);
1054
+ if (includeSource && entity.source !== void 0) validateSource(entity.source, entityId, out);
1055
+ }
1056
+ function validateCollection(collection, index, out) {
1057
+ if (!isRecord(collection)) {
1058
+ out.push(shape(`collections[${index}]`, "A collection must be an object."));
1059
+ return;
1060
+ }
1061
+ const entityId = isNonEmptyString(collection.id) ? collection.id : `collections[${index}]`;
1062
+ validateIdentity(collection, entityId, "collection", out);
1063
+ if (!isNonEmptyString(collection.default_mode_id)) {
1064
+ out.push(shape(entityId, "collection.default_mode_id must be a non-empty string."));
1065
+ }
1066
+ if (!Array.isArray(collection.modes)) {
1067
+ out.push(shape(entityId, "collection.modes must be an array."));
1068
+ } else {
1069
+ collection.modes.forEach((mode, modeIndex) => {
1070
+ if (!isRecord(mode)) {
1071
+ out.push(shape(entityId, `collection.modes[${modeIndex}] must be an object.`));
1072
+ return;
1073
+ }
1074
+ if (!isNonEmptyString(mode.id)) {
1075
+ out.push(shape(entityId, `collection.modes[${modeIndex}].id must be a non-empty string.`));
1076
+ }
1077
+ if (typeof mode.name !== "string") {
1078
+ out.push(shape(entityId, `collection.modes[${modeIndex}].name must be a string.`));
1079
+ }
1080
+ if (!isFiniteNumber(mode.order)) {
1081
+ out.push(shape(entityId, `collection.modes[${modeIndex}].order must be a finite number.`));
1082
+ }
1083
+ });
1084
+ }
1085
+ validateOptionalEntityMetadata(collection, entityId, out, true, false);
1086
+ }
1087
+ function validateToken(token, index, out) {
1088
+ if (!isRecord(token)) {
1089
+ out.push(shape(`tokens[${index}]`, "A token must be an object."));
1090
+ return;
1091
+ }
1092
+ const entityId = isNonEmptyString(token.id) ? token.id : `tokens[${index}]`;
1093
+ if (!isNonEmptyString(token.id)) {
1094
+ out.push(shape(entityId, "token.id must be a non-empty string."));
1095
+ }
1096
+ if (typeof token.collection_id !== "string" || token.collection_id.length === 0) {
1097
+ out.push(shape(entityId, "token.collection_id must be a non-empty string."));
1098
+ }
1099
+ if (typeof token.name !== "string") {
1100
+ out.push(shape(entityId, "token.name must be a string."));
1101
+ }
1102
+ if (!isStringArray(token.path) || token.path.length === 0) {
1103
+ out.push(shape(entityId, "token.path must be a non-empty array of strings."));
1104
+ }
1105
+ if (token.suggested_code_name !== void 0 && typeof token.suggested_code_name !== "string") {
1106
+ out.push(shape(entityId, "token.suggested_code_name must be a string when present."));
1107
+ }
1108
+ if (token.code_syntax !== void 0) {
1109
+ if (!isRecord(token.code_syntax) || Object.values(token.code_syntax).some((v) => typeof v !== "string")) {
1110
+ out.push(shape(entityId, "token.code_syntax must be an object of strings when present."));
1111
+ }
1112
+ }
1113
+ let tokenType;
1114
+ if (typeof token.type !== "string") {
1115
+ out.push(shape(entityId, "token.type must be a string."));
1116
+ } else if (!SUPPORTED_TOKEN_TYPES.includes(token.type)) {
1117
+ out.push(shape(entityId, `token.type is not a recognized token type: ${JSON.stringify(token.type)}.`));
1118
+ } else {
1119
+ tokenType = token.type;
1120
+ }
1121
+ if (typeof token.description !== "string") {
1122
+ out.push(shape(entityId, "token.description must be present and a string (an empty string is allowed)."));
1123
+ }
1124
+ if (!isStringArray(token.scopes)) {
1125
+ out.push(shape(entityId, "token.scopes must be an array of strings."));
1126
+ }
1127
+ validateOptionalEntityMetadata(token, entityId, out);
1128
+ if (!isRecord(token.values)) {
1129
+ out.push(shape(entityId, "token.values must be an object keyed by mode id."));
1130
+ return;
1131
+ }
1132
+ for (const [modeId, value] of Object.entries(token.values)) {
1133
+ validateValue(value, entityId, modeId, out, tokenType);
1134
+ }
1135
+ }
1136
+ function validateStyleProperty(property, entityId, propertyName, out) {
1137
+ if (!isRecord(property)) {
1138
+ out.push(shape(entityId, `typography.properties.${propertyName} must be an object.`));
1139
+ return;
1140
+ }
1141
+ const { source } = property;
1142
+ if (!isRecord(source)) {
1143
+ out.push(shape(entityId, `typography.properties.${propertyName}.source must be an object.`));
1144
+ } else if (source.kind === "alias") {
1145
+ if (!(source.target_id === null || typeof source.target_id === "string")) {
1146
+ out.push(shape(entityId, `typography.properties.${propertyName}.source.target_id must be a string or null.`));
1147
+ }
1148
+ if (!isStringArray(source.target_path)) {
1149
+ out.push(shape(entityId, `typography.properties.${propertyName}.source.target_path must be an array of strings.`));
1150
+ }
1151
+ } else if (source.kind !== "literal") {
1152
+ out.push(shape(entityId, `typography.properties.${propertyName}.source.kind must be literal or alias.`));
1153
+ }
1154
+ if (property.resolved !== null) {
1155
+ validateTypedValueEnvelope(property.resolved, entityId, out);
1156
+ }
1157
+ }
1158
+ var TYPOGRAPHY_STYLE_PROPERTIES = [
1159
+ "font_family",
1160
+ "font_weight",
1161
+ "font_size",
1162
+ "line_height",
1163
+ "letter_spacing",
1164
+ "paragraph_spacing",
1165
+ "paragraph_indent"
1166
+ ];
1167
+ function validateTypographyStyle(style, index, out) {
1168
+ if (!isRecord(style)) {
1169
+ out.push(shape(`styles.typography[${index}]`, "A typography style must be an object."));
1170
+ return;
1171
+ }
1172
+ const entityId = isNonEmptyString(style.id) ? style.id : `styles.typography[${index}]`;
1173
+ validateIdentity(style, entityId, "typography style", out);
1174
+ if (typeof style.description !== "string") {
1175
+ out.push(shape(entityId, "typography.description must be a string."));
1176
+ }
1177
+ if (!isRecord(style.properties)) {
1178
+ out.push(shape(entityId, "typography.properties must be an object."));
1179
+ } else {
1180
+ for (const propertyName of TYPOGRAPHY_STYLE_PROPERTIES) {
1181
+ validateStyleProperty(style.properties[propertyName], entityId, propertyName, out);
1182
+ }
1183
+ if (typeof style.properties.text_case !== "string") {
1184
+ out.push(shape(entityId, "typography.properties.text_case must be a string."));
1185
+ }
1186
+ if (typeof style.properties.text_decoration !== "string") {
1187
+ out.push(shape(entityId, "typography.properties.text_decoration must be a string."));
1188
+ }
1189
+ }
1190
+ validateOptionalEntityMetadata(style, entityId, out, true);
1191
+ }
1192
+ var EFFECT_KINDS = ["drop_shadow", "inner_shadow", "layer_blur", "background_blur"];
1193
+ function validateEffect(effect, entityId, index, out) {
1194
+ if (!isRecord(effect)) {
1195
+ out.push(shape(entityId, `effects[${index}] must be an object.`));
1196
+ return;
1197
+ }
1198
+ if (!EFFECT_KINDS.includes(effect.type)) {
1199
+ out.push(shape(entityId, `effects[${index}].type is not a recognized effect kind.`));
1200
+ }
1201
+ if (typeof effect.visible !== "boolean") {
1202
+ out.push(shape(entityId, `effects[${index}].visible must be a boolean.`));
1203
+ }
1204
+ if (effect.blend_mode !== void 0 && typeof effect.blend_mode !== "string") {
1205
+ out.push(shape(entityId, `effects[${index}].blend_mode must be a string when present.`));
1206
+ }
1207
+ if (effect.color !== void 0) {
1208
+ validateTypedValueEnvelope(effect.color, entityId, out, void 0, "color");
1209
+ }
1210
+ for (const field of ["offset_x", "offset_y", "blur", "spread"]) {
1211
+ if (effect[field] !== void 0) {
1212
+ validateTypedValueEnvelope(effect[field], entityId, out, void 0, "dimension");
1213
+ }
1214
+ }
1215
+ if (effect.show_behind_node !== void 0 && typeof effect.show_behind_node !== "boolean") {
1216
+ out.push(shape(entityId, `effects[${index}].show_behind_node must be a boolean when present.`));
1217
+ }
1218
+ }
1219
+ function validateEffectStyle(style, index, out) {
1220
+ if (!isRecord(style)) {
1221
+ out.push(shape(`styles.effects[${index}]`, "An effect style must be an object."));
1222
+ return;
1223
+ }
1224
+ const entityId = isNonEmptyString(style.id) ? style.id : `styles.effects[${index}]`;
1225
+ validateIdentity(style, entityId, "effect style", out);
1226
+ if (!(style.mode_id === null || typeof style.mode_id === "string")) {
1227
+ out.push(shape(entityId, "effect style.mode_id must be a string or null."));
1228
+ }
1229
+ if (!Array.isArray(style.effects)) {
1230
+ out.push(shape(entityId, "effect style.effects must be an array."));
1231
+ } else {
1232
+ style.effects.forEach((effect, effectIndex) => validateEffect(effect, entityId, effectIndex, out));
1233
+ }
1234
+ if (style.bindings !== void 0) {
1235
+ if (!Array.isArray(style.bindings)) {
1236
+ out.push(shape(entityId, "effect style.bindings must be an array when present."));
1237
+ } else {
1238
+ style.bindings.forEach((binding, bindingIndex) => {
1239
+ if (!isRecord(binding) || !isNonEmptyString(binding.property) || !isNonEmptyString(binding.token_id)) {
1240
+ out.push(shape(
1241
+ entityId,
1242
+ `effect style.bindings[${bindingIndex}] must carry non-empty property and token_id strings.`
1243
+ ));
1244
+ }
1245
+ });
1246
+ }
1247
+ }
1248
+ validateOptionalEntityMetadata(style, entityId, out, true);
1249
+ }
1250
+ var COMPLETENESS_VALUES = ["complete", "partial", "unavailable"];
1251
+ function validateRootSections(artifact, out) {
1252
+ if (!isRecord(artifact.spec_layer)) {
1253
+ out.push(shape(ROOT, "`spec_layer` must be an object."));
1254
+ }
1255
+ const completeness = artifact.completeness;
1256
+ if (!isRecord(completeness) || !COMPLETENESS_VALUES.includes(completeness.collections) || !COMPLETENESS_VALUES.includes(completeness.styles) || !isStringArray(completeness.unavailable_sources)) {
1257
+ out.push(shape(ROOT, "`completeness` must state collections/styles completeness and list unavailable sources."));
1258
+ }
1259
+ if (!Array.isArray(artifact.collections)) {
1260
+ out.push(shape(ROOT, "`collections` must be an array."));
1261
+ } else {
1262
+ artifact.collections.forEach((collection, index) => validateCollection(collection, index, out));
1263
+ }
1264
+ if (!isRecord(artifact.styles) || !Array.isArray(artifact.styles.typography) || !Array.isArray(artifact.styles.effects)) {
1265
+ out.push(shape(ROOT, "`styles` must be an object with `typography` and `effects` arrays."));
1266
+ } else {
1267
+ artifact.styles.typography.forEach((style, index) => validateTypographyStyle(style, index, out));
1268
+ artifact.styles.effects.forEach((style, index) => validateEffectStyle(style, index, out));
1269
+ }
1270
+ if (!Array.isArray(artifact.diagnostics)) {
1271
+ out.push(shape(ROOT, "`diagnostics` must be an array."));
1272
+ }
1273
+ if (!isRecord(artifact.statistics)) {
1274
+ out.push(shape(ROOT, "`statistics` must be an object."));
1275
+ }
1276
+ if (artifact.guidelines !== void 0) {
1277
+ const guidelines = artifact.guidelines;
1278
+ if (!isRecord(guidelines) || guidelines.origin !== "generated" || !isRecord(guidelines.group_descriptions)) {
1279
+ out.push(shape(
1280
+ ROOT,
1281
+ '`guidelines` must state origin "generated" and a group_descriptions object.'
1282
+ ));
1283
+ } else {
1284
+ for (const [collectionName, folders] of Object.entries(guidelines.group_descriptions)) {
1285
+ if (!isRecord(folders) || Object.values(folders).some((description) => typeof description !== "string")) {
1286
+ out.push(shape(
1287
+ ROOT,
1288
+ `guidelines.group_descriptions[${JSON.stringify(collectionName)}] must map folder names to strings.`
1289
+ ));
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+ }
1295
+ function validateLevel1(artifact) {
1296
+ try {
1297
+ const out = [];
1298
+ if (!isRecord(artifact)) {
1299
+ out.push(shape(ROOT, "The artifact root must be an object."));
1300
+ return out;
1301
+ }
1302
+ validateRootSections(artifact, out);
1303
+ if (!Array.isArray(artifact.tokens)) {
1304
+ out.push(shape(ROOT, "`tokens` must be an array."));
1305
+ } else {
1306
+ artifact.tokens.forEach((token, index) => validateToken(token, index, out));
1307
+ }
1308
+ return out;
1309
+ } catch (err) {
1310
+ const message = err instanceof Error && err.message ? `The artifact could not be read; accessing its properties threw: ${err.message}` : "The artifact could not be read; accessing its properties threw.";
1311
+ return [diagnostic("INCONSISTENT_VALUE_SHAPE", {
1312
+ entity_id: "artifact",
1313
+ message
1314
+ })];
1315
+ }
1316
+ }
1317
+
1318
+ // ../extractor/src/v5/dtcg.ts
1319
+ function dtcgSegments(name) {
1320
+ const segments = [];
1321
+ const notes = [];
1322
+ for (const raw of name.split("/")) {
1323
+ const parts = raw.includes(".") ? raw.split(".") : [raw];
1324
+ if (parts.length > 1) notes.push({ code: "segment_split", original: raw });
1325
+ for (const part of parts) {
1326
+ let out = part;
1327
+ if (out === "") out = "_";
1328
+ if (/[{}]/.test(out)) out = out.replace(/[{}]/g, "_");
1329
+ if (out.startsWith("$")) out = `_${out}`;
1330
+ if (out !== part) notes.push({ code: "name_escaped", original: part });
1331
+ segments.push(out);
1332
+ }
1333
+ }
1334
+ return { segments, notes };
1335
+ }
1336
+ var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1337
+ var RESERVED_FILE_NAMES = [
1338
+ "styles.typography.json",
1339
+ "styles.effects.json",
1340
+ "resolver.json",
1341
+ "spec-layer.meta.json",
1342
+ "report.json"
1343
+ ];
1344
+ function fileNameFor(collection, mode, taken) {
1345
+ const base = `${slug(collection.name)}.${slug(mode.name)}`;
1346
+ let candidate = `${base}.json`;
1347
+ let n = 1;
1348
+ while (taken.has(candidate)) {
1349
+ n += 1;
1350
+ candidate = `${base}-${n}.json`;
1351
+ }
1352
+ taken.add(candidate);
1353
+ return candidate;
1354
+ }
1355
+ function hexByte(n) {
1356
+ return Math.round(n * 255).toString(16).padStart(2, "0");
1357
+ }
1358
+ function colorComponents(color) {
1359
+ if (color.channels) return [color.channels[0], color.channels[1], color.channels[2]];
1360
+ const at = (i) => canonicalNumber(parseInt(color.hex.slice(i, i + 2), 16) / 255);
1361
+ return [at(1), at(3), at(5)];
1362
+ }
1363
+ function dtcgLiteral(value, scopes, style) {
1364
+ switch (value.type) {
1365
+ case "color": {
1366
+ if (style === "legacy") {
1367
+ const alpha = value.alpha === 1 ? "" : hexByte(value.alpha);
1368
+ return { $type: "color", $value: `${value.hex}${alpha}` };
1369
+ }
1370
+ return {
1371
+ $type: "color",
1372
+ $value: {
1373
+ colorSpace: "srgb",
1374
+ components: colorComponents(value),
1375
+ alpha: value.alpha,
1376
+ hex: value.hex
1377
+ }
1378
+ };
1379
+ }
1380
+ case "dimension": {
1381
+ if (value.unit !== "px" && value.unit !== "rem") {
1382
+ return { omit: "unit_not_expressible", details: { unit: value.unit, number: value.number } };
1383
+ }
1384
+ return {
1385
+ $type: "dimension",
1386
+ $value: style === "legacy" ? `${value.number}${value.unit}` : { value: value.number, unit: value.unit }
1387
+ };
1388
+ }
1389
+ case "duration":
1390
+ return {
1391
+ $type: "duration",
1392
+ $value: style === "legacy" ? `${value.number}${value.unit}` : { value: value.number, unit: value.unit }
1393
+ };
1394
+ case "number":
1395
+ return { $type: scopes.includes("FONT_WEIGHT") ? "fontWeight" : "number", $value: value.value };
1396
+ case "cubic_bezier":
1397
+ return { $type: "cubicBezier", $value: [...value.value] };
1398
+ case "font_family":
1399
+ return { $type: "fontFamily", $value: value.value };
1400
+ case "string":
1401
+ case "boolean":
1402
+ return { omit: "type_not_expressible", details: { type: value.type } };
1403
+ default: {
1404
+ const exhaustive = value;
1405
+ return exhaustive;
1406
+ }
1407
+ }
1408
+ }
1409
+ function setLeaf(tree, segments, leaf) {
1410
+ let node = tree;
1411
+ for (const seg of segments.slice(0, -1)) {
1412
+ const next = node[seg];
1413
+ if (typeof next !== "object" || next === null || Array.isArray(next)) node[seg] = {};
1414
+ node = node[seg];
1415
+ }
1416
+ node[segments[segments.length - 1]] = leaf;
1417
+ }
1418
+ var KEY_ORDER = ["$type", "$value", "$description", "$deprecated", "$extensions"];
1419
+ function sortTree(value) {
1420
+ if (Array.isArray(value)) return value.map(sortTree);
1421
+ if (typeof value !== "object" || value === null) return value;
1422
+ const rank = (k) => {
1423
+ const i = KEY_ORDER.indexOf(k);
1424
+ return i === -1 ? KEY_ORDER.length : i;
1425
+ };
1426
+ const keys = Object.keys(value).sort((a, b) => rank(a) - rank(b) || compareCodeUnits(a, b));
1427
+ return Object.fromEntries(keys.map((k) => [k, sortTree(value[k])]));
1428
+ }
1429
+ function reportOnce(p, entry2) {
1430
+ const key = JSON.stringify([entry2.code, entry2.path, entry2.mode ?? null, entry2.details]);
1431
+ if (p.reportKeys.has(key)) return;
1432
+ p.reportKeys.add(key);
1433
+ p.report.push(entry2);
1434
+ }
1435
+ function indexPaths(p) {
1436
+ const owners = /* @__PURE__ */ new Map();
1437
+ for (const token of p.artifact.tokens) {
1438
+ const collection = p.collectionById.get(token.collection_id);
1439
+ if (!collection) continue;
1440
+ const head = dtcgSegments(collection.name);
1441
+ const tail = dtcgSegments(token.name);
1442
+ const segments = [...head.segments, ...tail.segments];
1443
+ const path = segments.join(".");
1444
+ p.segmentsById.set(token.id, segments);
1445
+ for (const note of [...head.notes, ...tail.notes]) {
1446
+ reportOnce(p, {
1447
+ code: note.code,
1448
+ severity: note.code === "segment_split" ? "info" : "warning",
1449
+ path,
1450
+ message: note.code === "segment_split" ? `The segment "${note.original}" contains "." and was split into nested groups.` : `The segment "${note.original}" contains a character DTCG forbids and was escaped.`,
1451
+ details: { id: token.id, original: note.original }
1452
+ });
1453
+ }
1454
+ owners.set(path, [...owners.get(path) ?? [], token]);
1455
+ }
1456
+ for (const [path, tokens] of owners) {
1457
+ if (tokens.length === 1) {
1458
+ p.pathById.set(tokens[0].id, path);
1459
+ continue;
1460
+ }
1461
+ for (const token of tokens) {
1462
+ p.omittedIds.add(token.id);
1463
+ p.collidedIds.add(token.id);
1464
+ reportOnce(p, {
1465
+ code: "path_collision",
1466
+ severity: "error",
1467
+ path,
1468
+ message: `${tokens.length} tokens share this DTCG path after escaping; all were omitted.`,
1469
+ details: { id: token.id, ids: tokens.map((t) => t.id) }
1470
+ });
1471
+ }
1472
+ }
1473
+ }
1474
+ function modeName(collection, modeId) {
1475
+ return collection.modes.find((m) => m.id === modeId)?.name ?? modeId;
1476
+ }
1477
+ function unitOverrideFor(p, token, collection) {
1478
+ const units = p.options.units;
1479
+ if (!units) return void 0;
1480
+ for (const key of Object.keys(units).sort(compareCodeUnits)) {
1481
+ const slash = key.indexOf("/");
1482
+ if (slash === -1 || key.slice(0, slash) !== collection.name) continue;
1483
+ const glob = key.slice(slash + 1);
1484
+ const escaped = glob.split("*").map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join(".*");
1485
+ if (new RegExp(`^${escaped}$`).test(token.name)) return units[key];
1486
+ }
1487
+ return void 0;
1488
+ }
1489
+ var STATED_NUMBER_SCOPES = ["FONT_WEIGHT", "OPACITY"];
1490
+ function projectedLiteral(p, token, resolved2, onOverrideConflict) {
1491
+ const collection = p.collectionById.get(token.collection_id);
1492
+ const override = collection ? unitOverrideFor(p, token, collection) : void 0;
1493
+ let literal = resolved2;
1494
+ if (override !== void 0 && literal.type === "number") {
1495
+ if (token.scopes.some((s) => STATED_NUMBER_SCOPES.includes(s))) onOverrideConflict?.(override);
1496
+ else literal = { type: "dimension", number: literal.value, unit: override };
1497
+ }
1498
+ return dtcgLiteral(literal, token.scopes, p.options.values);
1499
+ }
1500
+ function aliasLeafType(p, token, chain, resolved2) {
1501
+ const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
1502
+ return projectedLiteral(p, terminal ?? token, resolved2);
1503
+ }
1504
+ function modeLabels(collection) {
1505
+ const counts = /* @__PURE__ */ new Map();
1506
+ for (const m of collection.modes) counts.set(m.name, (counts.get(m.name) ?? 0) + 1);
1507
+ return new Map(collection.modes.map((m) => [m.id, counts.get(m.name) === 1 ? m.name : `${m.name} [${m.id}]`]));
1508
+ }
1509
+ function collectionLabels(collections) {
1510
+ const counts = /* @__PURE__ */ new Map();
1511
+ for (const c of collections) counts.set(c.name, (counts.get(c.name) ?? 0) + 1);
1512
+ return new Map(collections.map(
1513
+ (c) => [c.id, counts.get(c.name) === 1 ? c.name : `${c.name} [${c.id}]`]
1514
+ ));
1515
+ }
1516
+ function modeLabelOf(p, collection, modeId) {
1517
+ return p.modeLabelsById.get(collection.id)?.get(modeId) ?? modeId;
1518
+ }
1519
+ var collectionLabelOf = (p, collection) => p.collectionLabelById.get(collection.id) ?? collection.name;
1520
+ function reportCollectionNameCollisions(p) {
1521
+ const owners = /* @__PURE__ */ new Map();
1522
+ for (const collection of p.artifact.collections) {
1523
+ owners.set(collection.name, [...owners.get(collection.name) ?? [], collection]);
1524
+ }
1525
+ for (const [name, collections] of owners) {
1526
+ if (collections.length < 2) continue;
1527
+ for (const collection of collections) {
1528
+ reportOnce(p, {
1529
+ code: "collection_name_collision",
1530
+ severity: "warning",
1531
+ path: collectionLabelOf(p, collection),
1532
+ message: `${collections.length} collections are named "${name}"; the resolver labels each one by its id.`,
1533
+ details: { id: collection.id, ids: collections.map((c) => c.id) }
1534
+ });
1535
+ }
1536
+ }
1537
+ }
1538
+ function asJson(value) {
1539
+ return JSON.parse(JSON.stringify(value));
1540
+ }
1541
+ function metaEntry(p, token, collection) {
1542
+ const labels = p.modeLabelsById.get(collection.id) ?? modeLabels(collection);
1543
+ const omitted = p.omittedIds.has(token.id);
1544
+ const plain = (v) => {
1545
+ if (v.kind === "literal" && (v.value.type === "boolean" || v.value.type === "string" || v.value.type === "number" || v.value.type === "font_family")) return v.value.value;
1546
+ return asJson(v);
1547
+ };
1548
+ return {
1549
+ id: token.id,
1550
+ collection_id: token.collection_id,
1551
+ type: token.type,
1552
+ scopes: [...token.scopes],
1553
+ ...token.code_syntax ? { code_syntax: token.code_syntax } : {},
1554
+ ...token.publication ? { publication: token.publication } : {},
1555
+ ...omitted ? {
1556
+ omitted: true,
1557
+ values: Object.fromEntries(Object.entries(token.values).map(([modeId, v]) => [labels.get(modeId) ?? modeId, plain(v)]))
1558
+ } : {}
1559
+ };
1560
+ }
1561
+ function reportDuplicateCodeSyntax(p) {
1562
+ const owners = /* @__PURE__ */ new Map();
1563
+ for (const token of p.artifact.tokens) {
1564
+ for (const [platform, identifier] of Object.entries(token.code_syntax ?? {})) {
1565
+ const key = JSON.stringify([platform, identifier]);
1566
+ owners.set(key, [...owners.get(key) ?? [], token]);
1567
+ }
1568
+ }
1569
+ for (const [key, tokens] of owners) {
1570
+ if (tokens.length < 2) continue;
1571
+ const [platform, identifier] = JSON.parse(key);
1572
+ for (const token of tokens) {
1573
+ reportOnce(p, {
1574
+ code: "duplicate_code_syntax",
1575
+ severity: "warning",
1576
+ path: p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name,
1577
+ message: `${tokens.length} tokens declare the ${platform} identifier "${identifier}".`,
1578
+ details: { id: token.id, platform, identifier, ids: tokens.map((t) => t.id) }
1579
+ });
1580
+ }
1581
+ }
1582
+ }
1583
+ var SPEC_LAYER_EXT = "com.spec-layer";
1584
+ function reportBindingDropped(p, path, property, targetId) {
1585
+ reportOnce(p, {
1586
+ code: "binding_dropped",
1587
+ severity: "warning",
1588
+ path,
1589
+ message: `The ${property} property is bound to a token this export does not carry; the resolved literal is written instead.`,
1590
+ details: {
1591
+ property,
1592
+ target_id: targetId,
1593
+ reason: p.tokenIds.has(targetId) ? "target_omitted" : "target_unavailable"
1594
+ }
1595
+ });
1596
+ }
1597
+ function styleMember(p, property, scopes, path, name) {
1598
+ if (property.source.kind === "alias" && property.source.target_id !== null) {
1599
+ const targetId = property.source.target_id;
1600
+ const target = p.omittedIds.has(targetId) ? void 0 : p.pathById.get(targetId);
1601
+ if (target !== void 0) return { value: `{${target}}` };
1602
+ reportBindingDropped(p, path, name, targetId);
1603
+ }
1604
+ if (property.resolved === null) {
1605
+ reportOnce(p, {
1606
+ code: "value_omitted",
1607
+ severity: "warning",
1608
+ path,
1609
+ message: `The ${name} property has no resolved value and was omitted.`,
1610
+ details: { property: name, reason: "source_unavailable" }
1611
+ });
1612
+ return null;
1613
+ }
1614
+ const converted = dtcgLiteral(property.resolved, scopes, p.options.values);
1615
+ if ("omit" in converted) {
1616
+ if (converted.omit === "unit_not_expressible") {
1617
+ reportOnce(p, {
1618
+ code: "unit_not_expressible",
1619
+ severity: "info",
1620
+ path,
1621
+ message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
1622
+ details: { property: name, ...converted.details }
1623
+ });
1624
+ const d = property.resolved;
1625
+ return { extension: { value: d.number, unit: d.unit } };
1626
+ }
1627
+ reportOnce(p, {
1628
+ code: "type_not_expressible",
1629
+ severity: "warning",
1630
+ path,
1631
+ message: `The ${name} property has a type DTCG cannot state and was omitted.`,
1632
+ details: { property: name, ...converted.details }
1633
+ });
1634
+ return null;
1635
+ }
1636
+ return { value: converted.$value };
1637
+ }
1638
+ var TYPOGRAPHY_MEMBERS = [
1639
+ ["font_family", "fontFamily", []],
1640
+ ["font_size", "fontSize", []],
1641
+ ["font_weight", "fontWeight", ["FONT_WEIGHT"]],
1642
+ ["line_height", "lineHeight", []],
1643
+ ["letter_spacing", "letterSpacing", []]
1644
+ ];
1645
+ var TYPOGRAPHY_EXTENSION_MEMBERS = [
1646
+ ["paragraph_spacing", "paragraphSpacing"],
1647
+ ["paragraph_indent", "paragraphIndent"]
1648
+ ];
1649
+ function typographyLeaf(p, style, path) {
1650
+ const value = {};
1651
+ const ext = {};
1652
+ for (const [key, name, scopes] of TYPOGRAPHY_MEMBERS) {
1653
+ const property = style.properties[key];
1654
+ if (key === "line_height" && property.resolved?.type === "dimension") {
1655
+ const boundTo = property.source.kind === "alias" ? property.source.target_id : null;
1656
+ let targetExported = false;
1657
+ if (property.resolved.unit === "%" && boundTo !== null) {
1658
+ targetExported = !p.omittedIds.has(boundTo) && p.pathById.get(boundTo) !== void 0;
1659
+ if (!targetExported) reportBindingDropped(p, path, name, boundTo);
1660
+ }
1661
+ if (property.resolved.unit === "%" && !targetExported) {
1662
+ value[name] = canonicalNumber(property.resolved.number / 100);
1663
+ continue;
1664
+ }
1665
+ reportOnce(p, {
1666
+ code: "unit_not_expressible",
1667
+ severity: "info",
1668
+ path,
1669
+ message: "DTCG line height is a unitless multiplier of the font size; the measured value is kept under $extensions.",
1670
+ // The binding is replaced by a literal here, so the entry names the
1671
+ // target it stood for; without it a consumer cannot tell this value
1672
+ // was bound at all.
1673
+ details: {
1674
+ property: name,
1675
+ unit: property.resolved.unit,
1676
+ number: property.resolved.number,
1677
+ ...boundTo !== null ? { target_id: boundTo } : {}
1678
+ }
1679
+ });
1680
+ ext[name] = { value: property.resolved.number, unit: property.resolved.unit };
1681
+ continue;
1682
+ }
1683
+ const member = styleMember(p, property, scopes, path, name);
1684
+ if (member === null) continue;
1685
+ if ("value" in member) value[name] = member.value;
1686
+ else ext[name] = member.extension;
1687
+ }
1688
+ for (const [key, name] of TYPOGRAPHY_EXTENSION_MEMBERS) {
1689
+ const member = styleMember(p, style.properties[key], [], path, name);
1690
+ if (member === null) continue;
1691
+ ext[name] = "value" in member ? member.value : member.extension;
1692
+ }
1693
+ ext.textCase = style.properties.text_case;
1694
+ ext.textDecoration = style.properties.text_decoration;
1695
+ return {
1696
+ $type: "typography",
1697
+ $value: value,
1698
+ ...style.description.length > 0 ? { $description: style.description } : {},
1699
+ $extensions: { [SPEC_LAYER_EXT]: ext }
1700
+ };
1701
+ }
1702
+ var SHADOW_FIELDS = [
1703
+ ["color", "color"],
1704
+ ["offset_x", "offsetX"],
1705
+ ["offset_y", "offsetY"],
1706
+ ["blur", "blur"],
1707
+ ["spread", "spread"]
1708
+ ];
1709
+ function effectLeaf(p, style, path) {
1710
+ const bindings = new Map((style.bindings ?? []).map((b) => [b.property, b.token_id]));
1711
+ const shadows = [];
1712
+ const layers = [];
1713
+ style.effects.forEach((effect, index) => {
1714
+ const isShadow = effect.type === "drop_shadow" || effect.type === "inner_shadow";
1715
+ const layer = { index, type: effect.type, visible: effect.visible };
1716
+ if (effect.blend_mode !== void 0) layer.blend_mode = effect.blend_mode;
1717
+ if (!isShadow && effect.blur) {
1718
+ const b = dtcgLiteral(effect.blur, [], p.options.values);
1719
+ if (!("omit" in b)) layer.blur = b.$value;
1720
+ }
1721
+ layers.push(layer);
1722
+ if (!isShadow || !effect.visible) return;
1723
+ const shadow = {};
1724
+ for (const [field, name] of SHADOW_FIELDS) {
1725
+ const boundId = bindings.get(`effects[${index}].${field}`);
1726
+ const boundPath = boundId !== void 0 && !p.omittedIds.has(boundId) ? p.pathById.get(boundId) : void 0;
1727
+ if (boundPath !== void 0) {
1728
+ shadow[name] = `{${boundPath}}`;
1729
+ continue;
1730
+ }
1731
+ if (boundId !== void 0) {
1732
+ reportBindingDropped(p, path, `effects[${index}].${field}`, boundId);
1733
+ }
1734
+ const raw = effect[field];
1735
+ if (raw === void 0) continue;
1736
+ const converted = dtcgLiteral(raw, [], p.options.values);
1737
+ if (!("omit" in converted)) shadow[name] = converted.$value;
1738
+ }
1739
+ shadow.inset = effect.type === "inner_shadow";
1740
+ shadows.push(shadow);
1741
+ });
1742
+ if (shadows.length === 0) {
1743
+ reportOnce(p, {
1744
+ code: "effect_not_expressible",
1745
+ severity: "warning",
1746
+ path,
1747
+ message: "The style has no visible shadow; DTCG has no blur type, so it is kept only under $extensions.",
1748
+ details: { id: style.id }
1749
+ });
1750
+ }
1751
+ return {
1752
+ $type: "shadow",
1753
+ $value: shadows,
1754
+ $extensions: { [SPEC_LAYER_EXT]: { layers } }
1755
+ };
1756
+ }
1757
+ function styleFiles(p) {
1758
+ const files = {};
1759
+ const build = (styles, root, file, leafOf) => {
1760
+ if (styles.length === 0) return;
1761
+ const tree = {};
1762
+ const seen = /* @__PURE__ */ new Map();
1763
+ for (const style of styles) {
1764
+ const segments = [root, ...dtcgSegments(style.name).segments];
1765
+ const path = segments.join(".");
1766
+ const other = seen.get(path);
1767
+ if (other !== void 0) {
1768
+ reportOnce(p, {
1769
+ code: "path_collision",
1770
+ severity: "error",
1771
+ path,
1772
+ message: "Two styles share this DTCG path after escaping; the later one was omitted.",
1773
+ details: { id: style.id, ids: [other, style.id] }
1774
+ });
1775
+ continue;
1776
+ }
1777
+ seen.set(path, style.id);
1778
+ setLeaf(tree, segments, leafOf(style, path));
1779
+ }
1780
+ files[file] = sortTree(tree);
1781
+ };
1782
+ build(
1783
+ p.artifact.styles.typography,
1784
+ "Typography styles",
1785
+ "styles.typography.json",
1786
+ (s, path) => typographyLeaf(p, s, path)
1787
+ );
1788
+ build(
1789
+ p.artifact.styles.effects,
1790
+ "Effect styles",
1791
+ "styles.effects.json",
1792
+ (s, path) => effectLeaf(p, s, path)
1793
+ );
1794
+ return files;
1795
+ }
1796
+ var pointer = (s) => s.replace(/~/g, "~0").replace(/\//g, "~1");
1797
+ var STYLE_ROOTS = {
1798
+ "styles.typography.json": "Typography styles",
1799
+ "styles.effects.json": "Effect styles"
1800
+ };
1801
+ function buildResolver(p, plans, styleFileNames) {
1802
+ const sets = {};
1803
+ const modifiers = {};
1804
+ const order = [];
1805
+ for (const collection of p.artifact.collections) {
1806
+ const own = plans.filter((f) => f.collection.id === collection.id);
1807
+ if (own.length === 0) continue;
1808
+ const labels = p.modeLabelsById.get(collection.id) ?? modeLabels(collection);
1809
+ const label = collectionLabelOf(p, collection);
1810
+ if (own.length === 1) {
1811
+ sets[label] = { sources: [{ $ref: own[0].file }] };
1812
+ order.push({ $ref: `#/sets/${pointer(label)}` });
1813
+ continue;
1814
+ }
1815
+ const contexts = {};
1816
+ for (const plan of own) contexts[labels.get(plan.modeId) ?? plan.modeId] = [{ $ref: plan.file }];
1817
+ const def = labels.get(collection.default_mode_id);
1818
+ modifiers[label] = { contexts, ...def !== void 0 ? { default: def } : {} };
1819
+ order.push({ $ref: `#/modifiers/${pointer(label)}` });
1820
+ }
1821
+ for (const file of styleFileNames) {
1822
+ const root = STYLE_ROOTS[file];
1823
+ sets[root] = { sources: [{ $ref: file }] };
1824
+ order.push({ $ref: `#/sets/${pointer(root)}` });
1825
+ }
1826
+ const fileName = p.artifact.spec_layer.source.file_name;
1827
+ return {
1828
+ version: "2025.10",
1829
+ ...typeof fileName === "string" && fileName.length > 0 ? { name: fileName } : {},
1830
+ sets,
1831
+ modifiers,
1832
+ resolutionOrder: order
1833
+ };
1834
+ }
1835
+ function annotateGroups(p, tree, collection) {
1836
+ const groups = p.artifact.guidelines?.group_descriptions[collection.name];
1837
+ if (!groups) return;
1838
+ const head = dtcgSegments(collection.name).segments;
1839
+ folders: for (const [folder, text] of Object.entries(groups)) {
1840
+ if (text.length === 0) continue;
1841
+ let node = tree;
1842
+ for (const seg of [...head, ...dtcgSegments(folder).segments]) {
1843
+ if (typeof node !== "object" || node === null || Array.isArray(node)) continue folders;
1844
+ node = node[seg];
1845
+ }
1846
+ if (typeof node === "object" && node !== null && !Array.isArray(node) && !("$value" in node)) {
1847
+ node.$description = text;
1848
+ }
1849
+ }
1850
+ }
1851
+ function foundationDtcg(artifact, options = {}) {
1852
+ const p = {
1853
+ artifact,
1854
+ options: { values: options.values ?? "standard", ...options.units ? { units: options.units } : {} },
1855
+ tokenById: new Map(artifact.tokens.map((t) => [t.id, t])),
1856
+ tokenIds: new Set(artifact.tokens.map((t) => t.id)),
1857
+ collectionById: new Map(artifact.collections.map((c) => [c.id, c])),
1858
+ collectionLabelById: collectionLabels(artifact.collections),
1859
+ modeLabelsById: new Map(artifact.collections.map((c) => [c.id, modeLabels(c)])),
1860
+ pathById: /* @__PURE__ */ new Map(),
1861
+ segmentsById: /* @__PURE__ */ new Map(),
1862
+ omittedIds: /* @__PURE__ */ new Set(),
1863
+ collidedIds: /* @__PURE__ */ new Set(),
1864
+ report: [],
1865
+ reportKeys: /* @__PURE__ */ new Set()
1866
+ };
1867
+ indexPaths(p);
1868
+ omitInexpressibleTypes(p);
1869
+ reportDuplicateCodeSyntax(p);
1870
+ reportCollectionNameCollisions(p);
1871
+ const files = {};
1872
+ const plans = [];
1873
+ const taken = new Set(RESERVED_FILE_NAMES);
1874
+ for (const collection of artifact.collections) {
1875
+ for (const mode of collection.modes) {
1876
+ const tree = {};
1877
+ for (const token of artifact.tokens) {
1878
+ if (token.collection_id !== collection.id || p.omittedIds.has(token.id)) continue;
1879
+ const leaf = tokenLeaf(p, token, collection, mode.id);
1880
+ if (leaf) setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
1881
+ }
1882
+ annotateGroups(p, tree, collection);
1883
+ const file = fileNameFor(collection, mode, taken);
1884
+ plans.push({ collection, modeId: mode.id, file });
1885
+ files[file] = sortTree(tree);
1886
+ }
1887
+ }
1888
+ const styles = styleFiles(p);
1889
+ Object.assign(files, styles);
1890
+ const resolver = buildResolver(p, plans, Object.keys(styles).sort(compareCodeUnits));
1891
+ p.report.sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
1892
+ const meta = {};
1893
+ for (const token of artifact.tokens) {
1894
+ const collection = p.collectionById.get(token.collection_id);
1895
+ if (!collection) continue;
1896
+ const path = p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name;
1897
+ meta[p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path] = metaEntry(p, token, collection);
1898
+ }
1899
+ const sortedMeta = Object.fromEntries(Object.entries(meta).sort(([a], [b]) => compareCodeUnits(a, b)));
1900
+ return { files, resolver, meta: sortedMeta, report: p.report };
1901
+ }
1902
+ function omitInexpressibleTypes(p) {
1903
+ for (const token of p.artifact.tokens) {
1904
+ if (token.type !== "string" && token.type !== "boolean") continue;
1905
+ p.omittedIds.add(token.id);
1906
+ reportOnce(p, {
1907
+ code: "type_not_expressible",
1908
+ severity: "warning",
1909
+ path: p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name,
1910
+ message: `DTCG has no ${token.type} type; the token was omitted.`,
1911
+ details: { id: token.id, type: token.type }
1912
+ });
1913
+ }
1914
+ }
1915
+ function tokenLeaf(p, token, collection, modeId) {
1916
+ const value = token.values[modeId];
1917
+ const path = p.pathById.get(token.id) ?? "";
1918
+ const mode = modeLabelOf(p, collection, modeId);
1919
+ const description = token.description.length > 0 ? { $description: token.description } : {};
1920
+ if (value === void 0 || value.kind === "missing") {
1921
+ reportOnce(p, {
1922
+ code: "value_omitted",
1923
+ severity: "warning",
1924
+ path,
1925
+ mode,
1926
+ message: "The token has no value for this mode.",
1927
+ details: { id: token.id, reason: value?.reason ?? "no_value_for_mode" }
1928
+ });
1929
+ return null;
1930
+ }
1931
+ if (value.kind === "alias") {
1932
+ if (value.resolved.status === "unresolved") {
1933
+ reportOnce(p, {
1934
+ code: "value_omitted",
1935
+ severity: "warning",
1936
+ path,
1937
+ mode,
1938
+ message: `The alias could not be resolved (${value.resolved.reason}); no value was written.`,
1939
+ details: {
1940
+ id: token.id,
1941
+ reason: value.resolved.reason,
1942
+ target_path: value.reference.target_path.join("/"),
1943
+ ...value.reference.target_id !== null ? { target_id: value.reference.target_id } : {},
1944
+ ...value.reference.source_library_name ? { source_library_name: value.reference.source_library_name } : {}
1945
+ }
1946
+ });
1947
+ return null;
1948
+ }
1949
+ const targetId = value.reference.target_id;
1950
+ const targetPath = targetId !== null && !p.omittedIds.has(targetId) ? p.pathById.get(targetId) : void 0;
1951
+ if (targetPath === void 0) {
1952
+ reportOnce(p, {
1953
+ code: "value_omitted",
1954
+ severity: "warning",
1955
+ path,
1956
+ mode,
1957
+ message: "The alias target was itself omitted from the DTCG output.",
1958
+ details: {
1959
+ id: token.id,
1960
+ reason: "target_omitted",
1961
+ target_path: value.reference.target_path.join("/"),
1962
+ ...targetId !== null ? { target_id: targetId } : {},
1963
+ ...value.reference.source_library_name ? { source_library_name: value.reference.source_library_name } : {}
1964
+ }
1965
+ });
1966
+ return null;
1967
+ }
1968
+ const target = targetId !== null ? p.tokenById.get(targetId) : void 0;
1969
+ const hop = value.resolved.chain[0];
1970
+ if (target && hop && target.collection_id !== token.collection_id) {
1971
+ const targetCollection = p.collectionById.get(target.collection_id);
1972
+ if (targetCollection !== void 0 && targetCollection.modes.length > 1 && modeName(targetCollection, hop.mode_id) !== modeName(collection, modeId)) {
1973
+ const hopMode = modeLabelOf(p, targetCollection, hop.mode_id);
1974
+ reportOnce(p, {
1975
+ code: "mode_selection_not_expressible",
1976
+ severity: "info",
1977
+ path,
1978
+ mode,
1979
+ message: `Figma resolved this alias through the target's "${hopMode}" mode; DTCG resolves it by the consumer's context.`,
1980
+ details: {
1981
+ id: token.id,
1982
+ target_id: targetId ?? "",
1983
+ target_mode: hopMode,
1984
+ resolved: asJson(value.resolved.value)
1985
+ }
1986
+ });
1987
+ }
1988
+ }
1989
+ const typed = aliasLeafType(p, token, value.resolved.chain, value.resolved.value);
1990
+ if ("omit" in typed) {
1991
+ reportOnce(p, {
1992
+ code: typed.omit,
1993
+ severity: "warning",
1994
+ path,
1995
+ mode,
1996
+ message: "The alias resolves to a value DTCG cannot state; the value was omitted.",
1997
+ details: { id: token.id, ...typed.details }
1998
+ });
1999
+ return null;
2000
+ }
2001
+ return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2002
+ }
2003
+ const converted = projectedLiteral(p, token, value.value, (override) => {
2004
+ reportOnce(p, {
2005
+ code: "unit_override_conflicts_with_scope",
2006
+ severity: "warning",
2007
+ path,
2008
+ message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
2009
+ details: { id: token.id, override, scopes: [...token.scopes] }
2010
+ });
2011
+ });
2012
+ if ("omit" in converted) {
2013
+ reportOnce(p, {
2014
+ code: converted.omit,
2015
+ severity: "warning",
2016
+ path,
2017
+ mode,
2018
+ message: converted.omit === "type_not_expressible" ? `DTCG has no ${String(converted.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted.details.unit)} value was omitted.`,
2019
+ details: { id: token.id, ...converted.details }
2020
+ });
2021
+ return null;
2022
+ }
2023
+ return { $type: converted.$type, $value: converted.$value, ...description };
2024
+ }
2025
+ function dtcgExportFiles(out) {
2026
+ const text = (v) => `${JSON.stringify(v, null, 2)}
2027
+ `;
2028
+ const files = {};
2029
+ for (const name of Object.keys(out.files).sort(compareCodeUnits)) files[name] = text(out.files[name]);
2030
+ files["resolver.json"] = text(out.resolver);
2031
+ files["spec-layer.meta.json"] = text(out.meta);
2032
+ files["report.json"] = text(out.report);
2033
+ return files;
2034
+ }
2035
+
739
2036
  // ../extractor/src/v5/componentContext.ts
740
2037
  var import_js_sha2563 = __toESM(require_sha256(), 1);
741
2038
 
@@ -748,14 +2045,14 @@ var LibraryBundleError = class extends Error {
748
2045
  this.code = code;
749
2046
  }
750
2047
  };
751
- var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2048
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
752
2049
  function hasContentHash(artifact) {
753
- if (!isRecord(artifact) || !isRecord(artifact.spec_layer)) return false;
2050
+ if (!isRecord2(artifact) || !isRecord2(artifact.spec_layer)) return false;
754
2051
  const exp = artifact.spec_layer.export;
755
- return isRecord(exp) && typeof exp.content_hash === "string";
2052
+ return isRecord2(exp) && typeof exp.content_hash === "string";
756
2053
  }
757
2054
  function entry(v, where) {
758
- if (!isRecord(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
2055
+ if (!isRecord2(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
759
2056
  throw new LibraryBundleError("malformed", `The ${where} entry in this bundle is malformed.`);
760
2057
  }
761
2058
  return { name: v.name, ai: v.ai, artifact: v.artifact };
@@ -772,7 +2069,7 @@ function parseLibraryBundle(input) {
772
2069
  throw new LibraryBundleError("not_json", "This is not valid JSON.");
773
2070
  }
774
2071
  }
775
- if (!isRecord(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
2072
+ if (!isRecord2(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
776
2073
  throw new LibraryBundleError("not_bundle", "This is not a Spec Layer library bundle.");
777
2074
  }
778
2075
  if (!supportedVersion(parsed.version)) {
@@ -788,7 +2085,7 @@ function parseLibraryBundle(input) {
788
2085
  const foundationRaw = parsed.foundation ?? null;
789
2086
  let foundation = null;
790
2087
  if (foundationRaw !== null) {
791
- if (!isRecord(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
2088
+ if (!isRecord2(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
792
2089
  throw new LibraryBundleError("malformed", "The foundation entry in this bundle is malformed.");
793
2090
  }
794
2091
  foundation = { ai: foundationRaw.ai, artifact: foundationRaw.artifact };
@@ -824,8 +2121,41 @@ function parseBundle(raw) {
824
2121
  }
825
2122
 
826
2123
  // src/config.ts
827
- import { readFileSync, writeFileSync, existsSync } from "node:fs";
2124
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
2125
+ import { join as join2 } from "node:path";
2126
+
2127
+ // src/credentials.ts
2128
+ import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
828
2129
  import { join } from "node:path";
2130
+ var CREDENTIALS_NAME = "speclayer.local.json";
2131
+ var unreadable = () => new Error(
2132
+ `${CREDENTIALS_NAME} cannot be read. Delete it, then run the setup command from the plugin's Library screen.`
2133
+ );
2134
+ function readCredentials(cwd) {
2135
+ const path = join(cwd, CREDENTIALS_NAME);
2136
+ if (!existsSync(path)) return null;
2137
+ let parsed;
2138
+ try {
2139
+ parsed = JSON.parse(readFileSync(path, "utf8"));
2140
+ } catch {
2141
+ throw unreadable();
2142
+ }
2143
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw unreadable();
2144
+ const record = parsed;
2145
+ if (typeof record.libraryId !== "string" || typeof record.key !== "string") throw unreadable();
2146
+ return { libraryId: record.libraryId, key: record.key };
2147
+ }
2148
+ function writeCredentials(cwd, stored) {
2149
+ const path = join(cwd, CREDENTIALS_NAME);
2150
+ const replaced = existsSync(path);
2151
+ const body = { libraryId: stored.libraryId, key: stored.key };
2152
+ writeFileSync(path, `${JSON.stringify(body, null, 2)}
2153
+ `, { mode: 384 });
2154
+ chmodSync(path, 384);
2155
+ return { replaced };
2156
+ }
2157
+
2158
+ // src/config.ts
829
2159
  var DEFAULT_API = "https://api.spec-layer.com";
830
2160
  var DEFAULT_OUT_DIR = ".speclayer";
831
2161
  var CONFIG_NAME = "speclayer.json";
@@ -842,12 +2172,29 @@ function parseInclude(value) {
842
2172
  components: record.components === void 0 ? null : record.components
843
2173
  };
844
2174
  }
2175
+ function parseDtcg(value) {
2176
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
2177
+ const record = value;
2178
+ const out = {};
2179
+ if (record.values !== void 0) {
2180
+ if (record.values !== "standard" && record.values !== "legacy") throw invalidConfig();
2181
+ out.values = record.values;
2182
+ }
2183
+ if (record.units !== void 0) {
2184
+ if (typeof record.units !== "object" || record.units === null || Array.isArray(record.units)) throw invalidConfig();
2185
+ for (const unit of Object.values(record.units)) {
2186
+ if (unit !== "px" && unit !== "rem") throw invalidConfig();
2187
+ }
2188
+ out.units = record.units;
2189
+ }
2190
+ return out;
2191
+ }
845
2192
  function readConfig(cwd) {
846
- const path = join(cwd, CONFIG_NAME);
847
- if (!existsSync(path)) return null;
2193
+ const path = join2(cwd, CONFIG_NAME);
2194
+ if (!existsSync2(path)) return null;
848
2195
  let parsed;
849
2196
  try {
850
- parsed = JSON.parse(readFileSync(path, "utf8"));
2197
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
851
2198
  } catch {
852
2199
  throw invalidConfig();
853
2200
  }
@@ -856,29 +2203,43 @@ function readConfig(cwd) {
856
2203
  return {
857
2204
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
858
2205
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
859
- ...record.include !== void 0 ? { include: parseInclude(record.include) } : {}
2206
+ ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2207
+ ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
860
2208
  };
861
2209
  }
862
2210
  function writeConfig(cwd, config) {
863
2211
  const body = {
864
2212
  libraryId: config.libraryId,
865
2213
  outDir: config.outDir,
866
- ...config.include ? { include: config.include } : {}
2214
+ ...config.include ? { include: config.include } : {},
2215
+ ...config.dtcg ? { dtcg: config.dtcg } : {}
867
2216
  };
868
- writeFileSync(join(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2217
+ writeFileSync2(join2(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
869
2218
  `);
870
2219
  }
871
2220
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
872
2221
  const config = readConfig(cwd);
873
2222
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
874
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join(cwd, outDir));
2223
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join2(cwd, outDir));
2224
+ const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2225
+ let storedKey = null;
2226
+ let storedKeyFor;
2227
+ if (!supplied) {
2228
+ const stored = readCredentials(cwd);
2229
+ if (stored) {
2230
+ if (libraryId && stored.libraryId === libraryId) storedKey = stored.key || null;
2231
+ else storedKeyFor = stored.libraryId;
2232
+ }
2233
+ }
875
2234
  return {
876
2235
  libraryId,
877
2236
  outDir,
878
2237
  // A trailing slash would build "//v1/..." paths the proxy router 404s on.
879
2238
  api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
880
- key: flags.key ?? env.SPEC_LAYER_KEY ?? null,
881
- ...config?.include ? { include: config.include } : {}
2239
+ key: supplied ?? storedKey,
2240
+ ...config?.include ? { include: config.include } : {},
2241
+ ...config?.dtcg ? { dtcg: config.dtcg } : {},
2242
+ ...storedKeyFor ? { storedKeyFor } : {}
882
2243
  };
883
2244
  }
884
2245
 
@@ -899,7 +2260,10 @@ async function fetchBundle(opts) {
899
2260
  }
900
2261
  if (res.status === 304) return { kind: "not_modified" };
901
2262
  if (res.status === 401) {
902
- return { kind: "error", message: "Key was rotated or revoked. Ask the publisher for the current key." };
2263
+ return {
2264
+ kind: "error",
2265
+ message: "Key was rotated or revoked. Run the setup command from the plugin's Library screen to store the current key."
2266
+ };
903
2267
  }
904
2268
  if (res.status === 404) return { kind: "error", message: "Library not found. It may have been unpublished." };
905
2269
  if (!res.ok) return { kind: "error", message: `Request failed with HTTP ${res.status}.` };
@@ -913,8 +2277,8 @@ async function fetchBundle(opts) {
913
2277
  }
914
2278
 
915
2279
  // src/files.ts
916
- import { mkdirSync, writeFileSync as writeFileSync2, readFileSync as readFileSync2, readdirSync, rmSync, renameSync, existsSync as existsSync2 } from "node:fs";
917
- import { join as join2, dirname, relative, resolve, isAbsolute } from "node:path";
2280
+ import { mkdirSync, writeFileSync as writeFileSync3, readFileSync as readFileSync3, readdirSync, rmSync, renameSync, existsSync as existsSync3 } from "node:fs";
2281
+ import { join as join3, dirname, relative, resolve, isAbsolute } from "node:path";
918
2282
 
919
2283
  // src/selection.ts
920
2284
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -949,23 +2313,23 @@ Available: ${available || "none"}.`
949
2313
 
950
2314
  // src/files.ts
951
2315
  function slugify(name) {
952
- const slug = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
953
- return slug || "component";
2316
+ const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
2317
+ return slug2 || "component";
954
2318
  }
955
2319
  function readManifest(outDir) {
956
- const path = join2(outDir, "manifest.json");
957
- if (!existsSync2(path)) return null;
2320
+ const path = join3(outDir, "manifest.json");
2321
+ if (!existsSync3(path)) return null;
958
2322
  try {
959
- return JSON.parse(readFileSync2(path, "utf8"));
2323
+ return JSON.parse(readFileSync3(path, "utf8"));
960
2324
  } catch {
961
2325
  return null;
962
2326
  }
963
2327
  }
964
2328
  function readLocalBundle(outDir) {
965
- const path = join2(outDir, "bundle.json");
966
- if (!existsSync2(path)) return null;
2329
+ const path = join3(outDir, "bundle.json");
2330
+ if (!existsSync3(path)) return null;
967
2331
  try {
968
- return parseBundle(readFileSync2(path, "utf8"));
2332
+ return parseBundle(readFileSync3(path, "utf8"));
969
2333
  } catch {
970
2334
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
971
2335
  }
@@ -975,20 +2339,20 @@ function componentSlugs(bundle) {
975
2339
  const nextSuffix = /* @__PURE__ */ new Map();
976
2340
  return bundle.components.map((component) => {
977
2341
  const base = slugify(component.name);
978
- let slug = base;
979
- if (usedSlugs.has(slug)) {
2342
+ let slug2 = base;
2343
+ if (usedSlugs.has(slug2)) {
980
2344
  let n = (nextSuffix.get(base) ?? 1) + 1;
981
- slug = `${base}-${n}`;
982
- while (usedSlugs.has(slug)) {
2345
+ slug2 = `${base}-${n}`;
2346
+ while (usedSlugs.has(slug2)) {
983
2347
  n += 1;
984
- slug = `${base}-${n}`;
2348
+ slug2 = `${base}-${n}`;
985
2349
  }
986
2350
  nextSuffix.set(base, n);
987
2351
  } else {
988
2352
  nextSuffix.set(base, 1);
989
2353
  }
990
- usedSlugs.add(slug);
991
- return slug;
2354
+ usedSlugs.add(slug2);
2355
+ return slug2;
992
2356
  });
993
2357
  }
994
2358
  function assertReplaceable(outDir, cwd) {
@@ -996,7 +2360,7 @@ function assertReplaceable(outDir, cwd) {
996
2360
  if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
997
2361
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
998
2362
  }
999
- if (existsSync2(outDir) && !existsSync2(join2(outDir, "manifest.json")) && readdirSync(outDir).length > 0) {
2363
+ if (existsSync3(outDir) && !existsSync3(join3(outDir, "manifest.json")) && readdirSync(outDir).length > 0) {
1000
2364
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
1001
2365
  }
1002
2366
  }
@@ -1009,17 +2373,25 @@ function writeBundleFiles(opts) {
1009
2373
  rmSync(staging, { recursive: true, force: true });
1010
2374
  const written = [];
1011
2375
  const put = (rel, content) => {
1012
- const path = join2(staging, rel);
2376
+ const path = join3(staging, rel);
1013
2377
  mkdirSync(dirname(path), { recursive: true });
1014
- writeFileSync2(path, content);
2378
+ writeFileSync3(path, content);
1015
2379
  written.push(rel);
1016
2380
  };
1017
2381
  try {
1018
2382
  put("bundle.json", opts.raw);
1019
2383
  const artifacts = [];
1020
2384
  if (opts.bundle.foundation) {
1021
- const aiPath = selection.foundation ? "ai/foundation.yaml" : null;
1022
- if (aiPath) put(aiPath, opts.bundle.foundation.ai);
2385
+ let aiPath = null;
2386
+ if (selection.foundation) {
2387
+ const artifact = opts.bundle.foundation.artifact;
2388
+ if (validateLevel1(artifact).some((d) => d.severity === "error")) {
2389
+ throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
2390
+ }
2391
+ const files = dtcgExportFiles(foundationDtcg(artifact, opts.dtcg ?? {}));
2392
+ for (const [name, text] of Object.entries(files)) put(`tokens/${name}`, text);
2393
+ aiPath = "tokens/resolver.json";
2394
+ }
1023
2395
  artifacts.push({
1024
2396
  kind: "foundation",
1025
2397
  name: "foundation",
@@ -1044,7 +2416,8 @@ function writeBundleFiles(opts) {
1044
2416
  pluginVersion: opts.bundle.pluginVersion,
1045
2417
  extractorVersion: opts.bundle.extractorVersion,
1046
2418
  selection,
1047
- artifacts
2419
+ artifacts,
2420
+ ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
1048
2421
  };
1049
2422
  put("manifest.json", `${JSON.stringify(manifest, null, 2)}
1050
2423
  `);
@@ -1057,6 +2430,60 @@ function writeBundleFiles(opts) {
1057
2430
  return written;
1058
2431
  }
1059
2432
 
2433
+ // src/gitignore.ts
2434
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "node:fs";
2435
+ import { spawnSync } from "node:child_process";
2436
+ import { join as join4, dirname as dirname2, resolve as resolve2 } from "node:path";
2437
+ var COMMENT = "# Spec Layer pull key, not for committing";
2438
+ function git(cwd, args) {
2439
+ const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
2440
+ if (res.error || res.status === null) return { ranGit: false };
2441
+ return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
2442
+ }
2443
+ function insideWorkTreeWithoutGit(cwd) {
2444
+ let dir = resolve2(cwd);
2445
+ for (; ; ) {
2446
+ if (existsSync4(join4(dir, ".git"))) return true;
2447
+ const parent = dirname2(dir);
2448
+ if (parent === dir) return false;
2449
+ dir = parent;
2450
+ }
2451
+ }
2452
+ function hasEntryLine(body, fileName) {
2453
+ return body.split("\n").some((line) => line.trim() === fileName);
2454
+ }
2455
+ function ensureIgnored(cwd, fileName) {
2456
+ const inWorkTree = git(cwd, ["rev-parse", "--is-inside-work-tree"]);
2457
+ if (!inWorkTree.ranGit) {
2458
+ return insideWorkTreeWithoutGit(cwd) ? { kind: "no-git", line: fileName } : { kind: "not-a-repo" };
2459
+ }
2460
+ if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
2461
+ const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
2462
+ if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
2463
+ const path = join4(cwd, ".gitignore");
2464
+ const existed = existsSync4(path);
2465
+ try {
2466
+ if (!existed) {
2467
+ writeFileSync4(path, `${COMMENT}
2468
+ ${fileName}
2469
+ `);
2470
+ } else {
2471
+ const body = readFileSync4(path, "utf8");
2472
+ if (!hasEntryLine(body, fileName)) {
2473
+ const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
2474
+ writeFileSync4(path, `${body}${lead}${COMMENT}
2475
+ ${fileName}
2476
+ `);
2477
+ }
2478
+ }
2479
+ } catch {
2480
+ return { kind: "refused", line: fileName };
2481
+ }
2482
+ const recheck = git(cwd, ["check-ignore", "-q", fileName]);
2483
+ if (!recheck.ranGit || recheck.status !== 0) return { kind: "still-not-ignored", line: fileName };
2484
+ return existed ? { kind: "added" } : { kind: "created" };
2485
+ }
2486
+
1060
2487
  // src/commands.ts
1061
2488
  var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
1062
2489
  function manifestReader() {
@@ -1066,9 +2493,17 @@ function manifestReader() {
1066
2493
  return cache.get(outDir) ?? null;
1067
2494
  };
1068
2495
  }
1069
- function sameSelection(a, b) {
1070
- const key = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
1071
- return key(a) === key(b);
2496
+ function sameOutput(a, b) {
2497
+ const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
2498
+ const dtcgKey = (d) => JSON.stringify(sortKeys(d ?? {}));
2499
+ return selectionKey(a.selection) === selectionKey(b.selection) && dtcgKey(a.dtcg) === dtcgKey(b.dtcg);
2500
+ }
2501
+ function sortKeys(value) {
2502
+ if (Array.isArray(value)) return value.map(sortKeys);
2503
+ if (value && typeof value === "object") {
2504
+ return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
2505
+ }
2506
+ return value;
1072
2507
  }
1073
2508
  var errorText = (err) => err instanceof Error ? err.message : String(err);
1074
2509
  function runInit(cwd, flags, io2) {
@@ -1086,7 +2521,7 @@ function runInit(cwd, flags, io2) {
1086
2521
  const outDir = flags.out ?? DEFAULT_OUT_DIR;
1087
2522
  writeConfig(cwd, { libraryId: flags.id, outDir, ...include ? { include } : {} });
1088
2523
  io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
1089
- io2.out("The pull key is never stored here. Set SPEC_LAYER_KEY in your environment or pass --key.");
2524
+ io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
1090
2525
  return 0;
1091
2526
  }
1092
2527
  function resolved(cwd, flags, env, io2, manifestAt) {
@@ -1098,18 +2533,18 @@ function resolved(cwd, flags, env, io2, manifestAt) {
1098
2533
  return null;
1099
2534
  }
1100
2535
  if (!opts.libraryId) {
1101
- io2.err("No library id. Pass --id lib_..., or run spec-layer init first.");
2536
+ io2.err(opts.storedKeyFor ? `No library id. ${CREDENTIALS_NAME} holds a key for library ${opts.storedKeyFor}. Pass --id ${opts.storedKeyFor}, or run spec-layer init first.` : "No library id. Pass --id lib_..., or run spec-layer init first.");
1102
2537
  return null;
1103
2538
  }
1104
2539
  if (!opts.key) {
1105
- io2.err("No pull key. Set SPEC_LAYER_KEY or pass --key.");
2540
+ io2.err(opts.storedKeyFor ? `The key in ${CREDENTIALS_NAME} was issued for library ${opts.storedKeyFor}, not ${opts.libraryId}. Run the setup command from the plugin's Library screen.` : "No pull key. Run the setup command from the plugin's Library screen, or set SPEC_LAYER_KEY.");
1106
2541
  return null;
1107
2542
  }
1108
2543
  return opts;
1109
2544
  }
1110
2545
  function resolvedOutDir(cwd, flags, io2) {
1111
2546
  try {
1112
- return join3(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
2547
+ return join5(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
1113
2548
  } catch (err) {
1114
2549
  io2.err(errorText(err));
1115
2550
  return null;
@@ -1122,6 +2557,78 @@ function describePull(bundle, selection, selected) {
1122
2557
  if (!bundle.foundation) return components;
1123
2558
  return selection.foundation ? `foundation + ${components}` : `${components}, no foundation`;
1124
2559
  }
2560
+ async function runSetup(cwd, flags, env, io2, fetcher) {
2561
+ if (!flags.id) {
2562
+ io2.err("spec-layer setup needs --id lib_... (shown in the plugin after publishing).");
2563
+ return 1;
2564
+ }
2565
+ const key = flags.key ?? env.SPEC_LAYER_KEY;
2566
+ if (!key) {
2567
+ io2.err("spec-layer setup needs --key sl_..., or SPEC_LAYER_KEY in the environment.");
2568
+ return 1;
2569
+ }
2570
+ let include;
2571
+ try {
2572
+ include = selectionFromFlags(flags);
2573
+ } catch (err) {
2574
+ io2.err(errorText(err));
2575
+ return 1;
2576
+ }
2577
+ let existing = null;
2578
+ try {
2579
+ existing = readConfig(cwd);
2580
+ } catch {
2581
+ existing = null;
2582
+ }
2583
+ const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
2584
+ const keptInclude = include ?? existing?.include ?? null;
2585
+ const keptDtcg = existing?.dtcg ?? null;
2586
+ writeConfig(cwd, {
2587
+ libraryId: flags.id,
2588
+ outDir,
2589
+ ...keptInclude ? { include: keptInclude } : {},
2590
+ ...keptDtcg ? { dtcg: keptDtcg } : {}
2591
+ });
2592
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
2593
+ const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
2594
+ switch (ignored.kind) {
2595
+ case "refused":
2596
+ io2.err(`Could not add ${CREDENTIALS_NAME} to .gitignore, so the key was not written.`);
2597
+ io2.err(`Add this line to .gitignore, then run the command again:
2598
+ ${ignored.line}`);
2599
+ return 1;
2600
+ case "no-git":
2601
+ io2.err(`Could not run git, so it could not confirm ${CREDENTIALS_NAME} would be ignored. The key was not written.`);
2602
+ io2.err(`Add this line to .gitignore, then run the command again:
2603
+ ${ignored.line}`);
2604
+ return 1;
2605
+ case "still-not-ignored":
2606
+ io2.err(`${ignored.line} is listed in .gitignore, but git still does not ignore it, so the key was not written.`);
2607
+ io2.err(`The most likely reason is that ${ignored.line} is already tracked. Run this, then run the command again:
2608
+ git rm --cached ${ignored.line}`);
2609
+ return 1;
2610
+ case "created":
2611
+ io2.out(`Created .gitignore with ${CREDENTIALS_NAME}.`);
2612
+ break;
2613
+ case "added":
2614
+ io2.out(`Added ${CREDENTIALS_NAME} to .gitignore.`);
2615
+ break;
2616
+ case "already":
2617
+ io2.out(`${CREDENTIALS_NAME} is already ignored by git.`);
2618
+ break;
2619
+ case "not-a-repo":
2620
+ io2.out("Not a git repository, so .gitignore was left alone.");
2621
+ break;
2622
+ default: {
2623
+ const exhaustive = ignored;
2624
+ void exhaustive;
2625
+ return 1;
2626
+ }
2627
+ }
2628
+ const { replaced } = writeCredentials(cwd, { libraryId: flags.id, key });
2629
+ io2.out(replaced ? `Replaced the stored key in ${CREDENTIALS_NAME}.` : `Stored the pull key in ${CREDENTIALS_NAME}.`);
2630
+ return runPull(cwd, { ...flags, key }, env, io2, fetcher);
2631
+ }
1125
2632
  async function runPull(cwd, flags, env, io2, fetcher) {
1126
2633
  const manifestAt = manifestReader();
1127
2634
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -1133,8 +2640,11 @@ async function runPull(cwd, flags, env, io2, fetcher) {
1133
2640
  io2.err(errorText(err));
1134
2641
  return 1;
1135
2642
  }
1136
- const manifest = manifestAt(join3(cwd, opts.outDir));
1137
- const etag = manifest && sameSelection(manifest.selection ?? DEFAULT_SELECTION, selection) ? manifest.bundleHash : void 0;
2643
+ const manifest = manifestAt(join5(cwd, opts.outDir));
2644
+ const etag = manifest && sameOutput(
2645
+ { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
2646
+ { selection, dtcg: opts.dtcg }
2647
+ ) ? manifest.bundleHash : void 0;
1138
2648
  const result = await fetchBundle({
1139
2649
  api: opts.api,
1140
2650
  libraryId: opts.libraryId,
@@ -1155,14 +2665,15 @@ async function runPull(cwd, flags, env, io2, fetcher) {
1155
2665
  const bundle = parseBundle(result.raw);
1156
2666
  const selected = selectComponents(bundle, selection);
1157
2667
  written = writeBundleFiles({
1158
- outDir: join3(cwd, opts.outDir),
2668
+ outDir: join5(cwd, opts.outDir),
1159
2669
  cwd,
1160
2670
  raw: result.raw,
1161
2671
  bundle,
1162
2672
  selection,
1163
2673
  libraryId: opts.libraryId,
1164
2674
  publishedAt: result.publishedAt,
1165
- bundleHash: result.bundleHash
2675
+ bundleHash: result.bundleHash,
2676
+ dtcg: opts.dtcg
1166
2677
  });
1167
2678
  io2.out(
1168
2679
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
@@ -1178,7 +2689,7 @@ async function runStatus(cwd, flags, env, io2, fetcher) {
1178
2689
  const manifestAt = manifestReader();
1179
2690
  const opts = resolved(cwd, flags, env, io2, manifestAt);
1180
2691
  if (!opts) return 1;
1181
- const manifest = manifestAt(join3(cwd, opts.outDir));
2692
+ const manifest = manifestAt(join5(cwd, opts.outDir));
1182
2693
  if (!manifest) {
1183
2694
  io2.err(NO_LOCAL_PULL);
1184
2695
  return 2;
@@ -1269,21 +2780,23 @@ Available: ${available || "none"}.`);
1269
2780
  var USAGE = `spec-layer <command>
1270
2781
 
1271
2782
  Commands:
2783
+ setup --id lib_... --key sl_... [--out DIR] [selection]
2784
+ store the key, then pull
1272
2785
  init --id lib_... [--out DIR] [selection] write speclayer.json
1273
2786
  pull [--id lib_...] [--key sl_...] [selection]
1274
- fetch the library into DIR (default .speclayer)
2787
+ fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
1275
2788
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
1276
2789
  list list every artifact in the last pull
1277
2790
  show foundation | component NAME [--canonical]
1278
- print one artifact's AI YAML (or canonical JSON)
2791
+ print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
1279
2792
 
1280
- Selection (pull and init; flags replace the include block in speclayer.json):
2793
+ Selection (setup, pull and init; flags replace the include block in speclayer.json):
1281
2794
  --only foundation | components write just the foundation, or just components
1282
2795
  --component NAME write only this component (repeatable, matched by slug)
1283
2796
 
1284
2797
  Options:
1285
2798
  --api URL override the API origin (default https://api.spec-layer.com)
1286
- The pull key comes from --key or the SPEC_LAYER_KEY environment variable.`;
2799
+ The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
1287
2800
  var io = {
1288
2801
  out: (l) => console.log(l),
1289
2802
  err: (l) => console.error(l),
@@ -1314,6 +2827,7 @@ async function main() {
1314
2827
  const command = positionals[0];
1315
2828
  const cwd = process.cwd();
1316
2829
  try {
2830
+ if (command === "setup") return await runSetup(cwd, values, process.env, io);
1317
2831
  if (command === "init") return runInit(cwd, values, io);
1318
2832
  if (command === "pull") return await runPull(cwd, values, process.env, io);
1319
2833
  if (command === "status") return await runStatus(cwd, values, process.env, io);
@@ -1326,6 +2840,10 @@ async function main() {
1326
2840
  return 1;
1327
2841
  }
1328
2842
  }
2843
+ process.stdout.on("error", (err) => {
2844
+ if (err.code === "EPIPE") process.exit(process.exitCode ?? 0);
2845
+ throw err;
2846
+ });
1329
2847
  process.exitCode = await main();
1330
2848
  /*! Bundled license information:
1331
2849