spec-layer 0.3.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +116 -12
- package/dist/cli.js +2131 -69
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -262,7 +262,7 @@ var require_sha256 = __commonJS({
|
|
|
262
262
|
}
|
|
263
263
|
notString = true;
|
|
264
264
|
}
|
|
265
|
-
var
|
|
265
|
+
var code2, index = 0, i, length = message.length, blocks2 = this.blocks;
|
|
266
266
|
while (index < length) {
|
|
267
267
|
if (this.hashed) {
|
|
268
268
|
this.hashed = false;
|
|
@@ -275,22 +275,22 @@ var require_sha256 = __commonJS({
|
|
|
275
275
|
}
|
|
276
276
|
} else {
|
|
277
277
|
for (i = this.start; index < length && i < 64; ++index) {
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
blocks2[i >>> 2] |=
|
|
281
|
-
} else if (
|
|
282
|
-
blocks2[i >>> 2] |= (192 |
|
|
283
|
-
blocks2[i >>> 2] |= (128 |
|
|
284
|
-
} else if (
|
|
285
|
-
blocks2[i >>> 2] |= (224 |
|
|
286
|
-
blocks2[i >>> 2] |= (128 |
|
|
287
|
-
blocks2[i >>> 2] |= (128 |
|
|
278
|
+
code2 = message.charCodeAt(index);
|
|
279
|
+
if (code2 < 128) {
|
|
280
|
+
blocks2[i >>> 2] |= code2 << SHIFT[i++ & 3];
|
|
281
|
+
} else if (code2 < 2048) {
|
|
282
|
+
blocks2[i >>> 2] |= (192 | code2 >>> 6) << SHIFT[i++ & 3];
|
|
283
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
284
|
+
} else if (code2 < 55296 || code2 >= 57344) {
|
|
285
|
+
blocks2[i >>> 2] |= (224 | code2 >>> 12) << SHIFT[i++ & 3];
|
|
286
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
|
|
287
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
288
288
|
} else {
|
|
289
|
-
|
|
290
|
-
blocks2[i >>> 2] |= (240 |
|
|
291
|
-
blocks2[i >>> 2] |= (128 |
|
|
292
|
-
blocks2[i >>> 2] |= (128 |
|
|
293
|
-
blocks2[i >>> 2] |= (128 |
|
|
289
|
+
code2 = 65536 + ((code2 & 1023) << 10 | message.charCodeAt(++index) & 1023);
|
|
290
|
+
blocks2[i >>> 2] |= (240 | code2 >>> 18) << SHIFT[i++ & 3];
|
|
291
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 12 & 63) << SHIFT[i++ & 3];
|
|
292
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
|
|
293
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
}
|
|
@@ -472,24 +472,24 @@ var require_sha256 = __commonJS({
|
|
|
472
472
|
function HmacSha256(key, is224, sharedMemory) {
|
|
473
473
|
var i, type = typeof key;
|
|
474
474
|
if (type === "string") {
|
|
475
|
-
var bytes = [], length = key.length, index = 0,
|
|
475
|
+
var bytes = [], length = key.length, index = 0, code2;
|
|
476
476
|
for (i = 0; i < length; ++i) {
|
|
477
|
-
|
|
478
|
-
if (
|
|
479
|
-
bytes[index++] =
|
|
480
|
-
} else if (
|
|
481
|
-
bytes[index++] = 192 |
|
|
482
|
-
bytes[index++] = 128 |
|
|
483
|
-
} else if (
|
|
484
|
-
bytes[index++] = 224 |
|
|
485
|
-
bytes[index++] = 128 |
|
|
486
|
-
bytes[index++] = 128 |
|
|
477
|
+
code2 = key.charCodeAt(i);
|
|
478
|
+
if (code2 < 128) {
|
|
479
|
+
bytes[index++] = code2;
|
|
480
|
+
} else if (code2 < 2048) {
|
|
481
|
+
bytes[index++] = 192 | code2 >>> 6;
|
|
482
|
+
bytes[index++] = 128 | code2 & 63;
|
|
483
|
+
} else if (code2 < 55296 || code2 >= 57344) {
|
|
484
|
+
bytes[index++] = 224 | code2 >>> 12;
|
|
485
|
+
bytes[index++] = 128 | code2 >>> 6 & 63;
|
|
486
|
+
bytes[index++] = 128 | code2 & 63;
|
|
487
487
|
} else {
|
|
488
|
-
|
|
489
|
-
bytes[index++] = 240 |
|
|
490
|
-
bytes[index++] = 128 |
|
|
491
|
-
bytes[index++] = 128 |
|
|
492
|
-
bytes[index++] = 128 |
|
|
488
|
+
code2 = 65536 + ((code2 & 1023) << 10 | key.charCodeAt(++i) & 1023);
|
|
489
|
+
bytes[index++] = 240 | code2 >>> 18;
|
|
490
|
+
bytes[index++] = 128 | code2 >>> 12 & 63;
|
|
491
|
+
bytes[index++] = 128 | code2 >>> 6 & 63;
|
|
492
|
+
bytes[index++] = 128 | code2 & 63;
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
key = bytes;
|
|
@@ -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
|
|
562
|
+
import { join as join7 } 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(code2, fields) {
|
|
646
|
+
return {
|
|
647
|
+
code: code2,
|
|
648
|
+
severity: DEFAULT_SEVERITY[code2],
|
|
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,29 +798,1261 @@ 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
|
|
|
742
2039
|
// ../extractor/src/libraryBundle.ts
|
|
743
2040
|
var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
|
|
744
2041
|
var LibraryBundleError = class extends Error {
|
|
745
|
-
constructor(
|
|
2042
|
+
constructor(code2, message) {
|
|
746
2043
|
super(message);
|
|
747
2044
|
this.name = "LibraryBundleError";
|
|
748
|
-
this.code =
|
|
2045
|
+
this.code = code2;
|
|
749
2046
|
}
|
|
750
2047
|
};
|
|
751
|
-
var
|
|
2048
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
752
2049
|
function hasContentHash(artifact) {
|
|
753
|
-
if (!
|
|
2050
|
+
if (!isRecord2(artifact) || !isRecord2(artifact.spec_layer)) return false;
|
|
754
2051
|
const exp = artifact.spec_layer.export;
|
|
755
|
-
return
|
|
2052
|
+
return isRecord2(exp) && typeof exp.content_hash === "string";
|
|
756
2053
|
}
|
|
757
2054
|
function entry(v, where) {
|
|
758
|
-
if (!
|
|
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 (!
|
|
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 (!
|
|
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 };
|
|
@@ -875,6 +2172,23 @@ function parseInclude(value) {
|
|
|
875
2172
|
components: record.components === void 0 ? null : record.components
|
|
876
2173
|
};
|
|
877
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
|
+
}
|
|
878
2192
|
function readConfig(cwd) {
|
|
879
2193
|
const path = join2(cwd, CONFIG_NAME);
|
|
880
2194
|
if (!existsSync2(path)) return null;
|
|
@@ -889,14 +2203,16 @@ function readConfig(cwd) {
|
|
|
889
2203
|
return {
|
|
890
2204
|
...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
|
|
891
2205
|
...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
|
|
892
|
-
...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) } : {}
|
|
893
2208
|
};
|
|
894
2209
|
}
|
|
895
2210
|
function writeConfig(cwd, config) {
|
|
896
2211
|
const body = {
|
|
897
2212
|
libraryId: config.libraryId,
|
|
898
2213
|
outDir: config.outDir,
|
|
899
|
-
...config.include ? { include: config.include } : {}
|
|
2214
|
+
...config.include ? { include: config.include } : {},
|
|
2215
|
+
...config.dtcg ? { dtcg: config.dtcg } : {}
|
|
900
2216
|
};
|
|
901
2217
|
writeFileSync2(join2(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
|
|
902
2218
|
`);
|
|
@@ -922,6 +2238,7 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
|
922
2238
|
api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
|
|
923
2239
|
key: supplied ?? storedKey,
|
|
924
2240
|
...config?.include ? { include: config.include } : {},
|
|
2241
|
+
...config?.dtcg ? { dtcg: config.dtcg } : {},
|
|
925
2242
|
...storedKeyFor ? { storedKeyFor } : {}
|
|
926
2243
|
};
|
|
927
2244
|
}
|
|
@@ -996,8 +2313,8 @@ Available: ${available || "none"}.`
|
|
|
996
2313
|
|
|
997
2314
|
// src/files.ts
|
|
998
2315
|
function slugify(name) {
|
|
999
|
-
const
|
|
1000
|
-
return
|
|
2316
|
+
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2317
|
+
return slug2 || "component";
|
|
1001
2318
|
}
|
|
1002
2319
|
function readManifest(outDir) {
|
|
1003
2320
|
const path = join3(outDir, "manifest.json");
|
|
@@ -1022,20 +2339,20 @@ function componentSlugs(bundle) {
|
|
|
1022
2339
|
const nextSuffix = /* @__PURE__ */ new Map();
|
|
1023
2340
|
return bundle.components.map((component) => {
|
|
1024
2341
|
const base = slugify(component.name);
|
|
1025
|
-
let
|
|
1026
|
-
if (usedSlugs.has(
|
|
2342
|
+
let slug2 = base;
|
|
2343
|
+
if (usedSlugs.has(slug2)) {
|
|
1027
2344
|
let n = (nextSuffix.get(base) ?? 1) + 1;
|
|
1028
|
-
|
|
1029
|
-
while (usedSlugs.has(
|
|
2345
|
+
slug2 = `${base}-${n}`;
|
|
2346
|
+
while (usedSlugs.has(slug2)) {
|
|
1030
2347
|
n += 1;
|
|
1031
|
-
|
|
2348
|
+
slug2 = `${base}-${n}`;
|
|
1032
2349
|
}
|
|
1033
2350
|
nextSuffix.set(base, n);
|
|
1034
2351
|
} else {
|
|
1035
2352
|
nextSuffix.set(base, 1);
|
|
1036
2353
|
}
|
|
1037
|
-
usedSlugs.add(
|
|
1038
|
-
return
|
|
2354
|
+
usedSlugs.add(slug2);
|
|
2355
|
+
return slug2;
|
|
1039
2356
|
});
|
|
1040
2357
|
}
|
|
1041
2358
|
function assertReplaceable(outDir, cwd) {
|
|
@@ -1065,8 +2382,16 @@ function writeBundleFiles(opts) {
|
|
|
1065
2382
|
put("bundle.json", opts.raw);
|
|
1066
2383
|
const artifacts = [];
|
|
1067
2384
|
if (opts.bundle.foundation) {
|
|
1068
|
-
|
|
1069
|
-
if (
|
|
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
|
+
}
|
|
1070
2395
|
artifacts.push({
|
|
1071
2396
|
kind: "foundation",
|
|
1072
2397
|
name: "foundation",
|
|
@@ -1091,7 +2416,8 @@ function writeBundleFiles(opts) {
|
|
|
1091
2416
|
pluginVersion: opts.bundle.pluginVersion,
|
|
1092
2417
|
extractorVersion: opts.bundle.extractorVersion,
|
|
1093
2418
|
selection,
|
|
1094
|
-
artifacts
|
|
2419
|
+
artifacts,
|
|
2420
|
+
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
|
|
1095
2421
|
};
|
|
1096
2422
|
put("manifest.json", `${JSON.stringify(manifest, null, 2)}
|
|
1097
2423
|
`);
|
|
@@ -1158,6 +2484,619 @@ ${fileName}
|
|
|
1158
2484
|
return existed ? { kind: "added" } : { kind: "created" };
|
|
1159
2485
|
}
|
|
1160
2486
|
|
|
2487
|
+
// src/detect.ts
|
|
2488
|
+
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
|
|
2489
|
+
import { join as join5 } from "node:path";
|
|
2490
|
+
var CODE_SYNTAX_KEY = {
|
|
2491
|
+
web: "WEB",
|
|
2492
|
+
ios: "iOS",
|
|
2493
|
+
android: "ANDROID",
|
|
2494
|
+
flutter: null
|
|
2495
|
+
};
|
|
2496
|
+
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2497
|
+
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2498
|
+
var uniq = (xs) => [...new Set(xs)];
|
|
2499
|
+
function readPackageJson(cwd) {
|
|
2500
|
+
const path = join5(cwd, "package.json");
|
|
2501
|
+
if (!existsSync5(path)) return null;
|
|
2502
|
+
let parsed;
|
|
2503
|
+
try {
|
|
2504
|
+
parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2505
|
+
} catch {
|
|
2506
|
+
return null;
|
|
2507
|
+
}
|
|
2508
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2509
|
+
const record = parsed;
|
|
2510
|
+
const deps = {};
|
|
2511
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2512
|
+
const block = record[field];
|
|
2513
|
+
if (typeof block !== "object" || block === null) continue;
|
|
2514
|
+
for (const [name, range] of Object.entries(block)) {
|
|
2515
|
+
if (typeof range === "string") deps[name] = range;
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
return { deps };
|
|
2519
|
+
}
|
|
2520
|
+
function majorOf(range) {
|
|
2521
|
+
const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
|
|
2522
|
+
return m ? Number(m[1]) : null;
|
|
2523
|
+
}
|
|
2524
|
+
var DEP_SIGNALS = [
|
|
2525
|
+
{ dep: "react", platform: "web", framework: "react" },
|
|
2526
|
+
{ dep: "next", platform: "web", framework: "next" },
|
|
2527
|
+
{ dep: "vue", platform: "web", framework: "vue" },
|
|
2528
|
+
{ dep: "nuxt", platform: "web", framework: "nuxt" },
|
|
2529
|
+
{ dep: "svelte", platform: "web", framework: "svelte" },
|
|
2530
|
+
{ dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
|
|
2531
|
+
{ dep: "@angular/core", platform: "web", framework: "angular" },
|
|
2532
|
+
{ dep: "solid-js", platform: "web", framework: "solid" },
|
|
2533
|
+
{ dep: "lit", platform: "web", framework: "lit" },
|
|
2534
|
+
{ dep: "astro", platform: "web", framework: "astro" },
|
|
2535
|
+
{ dep: "react-native", platform: "ios", framework: "react-native" },
|
|
2536
|
+
{ dep: "expo", platform: "ios", framework: "expo" },
|
|
2537
|
+
{ dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
|
|
2538
|
+
{ dep: "styled-components", platform: "web", framework: "styled-components" },
|
|
2539
|
+
{ dep: "@emotion/react", platform: "web", framework: "emotion" },
|
|
2540
|
+
{ dep: "sass", platform: "web", framework: "sass" },
|
|
2541
|
+
{ dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
|
|
2542
|
+
{ dep: "@stitches/react", platform: "web", framework: "stitches" },
|
|
2543
|
+
{ dep: "@pandacss/dev", platform: "web", framework: "panda" },
|
|
2544
|
+
{ dep: "style-dictionary", tokenTool: "style-dictionary" },
|
|
2545
|
+
{ dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
|
|
2546
|
+
{ dep: "typescript", language: "typescript" }
|
|
2547
|
+
];
|
|
2548
|
+
var FILE_SIGNALS = [
|
|
2549
|
+
{ test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
|
|
2550
|
+
{ test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
|
|
2551
|
+
{ test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
|
|
2552
|
+
{ test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
|
|
2553
|
+
{ test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
|
|
2554
|
+
{ test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
|
|
2555
|
+
{ test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
|
|
2556
|
+
{ test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
|
|
2557
|
+
{ test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
|
|
2558
|
+
{ test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
|
|
2559
|
+
{ test: (n) => n === "go.mod", signal: "Go module", language: "go" },
|
|
2560
|
+
{ test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
|
|
2561
|
+
{ test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
|
|
2562
|
+
{ test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
|
|
2563
|
+
{ test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
|
|
2564
|
+
{ test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
|
|
2565
|
+
{ test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
|
|
2566
|
+
{ test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
|
|
2567
|
+
{ test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
|
|
2568
|
+
{ test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
|
|
2569
|
+
{ test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
|
|
2570
|
+
{ test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
|
|
2571
|
+
{ test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
|
|
2572
|
+
{ test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
|
|
2573
|
+
];
|
|
2574
|
+
function detectRepo(cwd) {
|
|
2575
|
+
const platforms = [];
|
|
2576
|
+
const languages = [];
|
|
2577
|
+
const frameworks = [];
|
|
2578
|
+
const tokenTools = [];
|
|
2579
|
+
const agents = [];
|
|
2580
|
+
const evidence = [];
|
|
2581
|
+
let styleDictionaryMajor = null;
|
|
2582
|
+
let names = [];
|
|
2583
|
+
try {
|
|
2584
|
+
names = readdirSync2(cwd).sort();
|
|
2585
|
+
} catch {
|
|
2586
|
+
names = [];
|
|
2587
|
+
}
|
|
2588
|
+
for (const name of names) {
|
|
2589
|
+
for (const rule of FILE_SIGNALS) {
|
|
2590
|
+
if (!rule.test(name)) continue;
|
|
2591
|
+
evidence.push({ signal: rule.signal, file: name });
|
|
2592
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2593
|
+
if (rule.language) languages.push(rule.language);
|
|
2594
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2595
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2596
|
+
if (rule.agent) agents.push(rule.agent);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
if (existsSync5(join5(cwd, ".github", "copilot-instructions.md")) || existsSync5(join5(cwd, ".github", "instructions"))) {
|
|
2600
|
+
evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
|
|
2601
|
+
agents.push("copilot");
|
|
2602
|
+
}
|
|
2603
|
+
const pkg = readPackageJson(cwd);
|
|
2604
|
+
if (pkg) {
|
|
2605
|
+
for (const rule of DEP_SIGNALS) {
|
|
2606
|
+
const range = pkg.deps[rule.dep];
|
|
2607
|
+
if (range === void 0) continue;
|
|
2608
|
+
evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
|
|
2609
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2610
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2611
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2612
|
+
if (rule.language) languages.push(rule.language);
|
|
2613
|
+
if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
|
|
2614
|
+
}
|
|
2615
|
+
if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
|
|
2616
|
+
}
|
|
2617
|
+
const order = (p) => PLATFORMS.indexOf(p);
|
|
2618
|
+
const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
|
|
2619
|
+
return {
|
|
2620
|
+
platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
|
|
2621
|
+
languages: uniq(languages).sort(),
|
|
2622
|
+
frameworks: uniq(frameworks).sort(),
|
|
2623
|
+
tokenTools: uniq(tokenTools).sort(),
|
|
2624
|
+
agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
|
|
2625
|
+
styleDictionaryMajor,
|
|
2626
|
+
evidence
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
function isPlatform(value) {
|
|
2630
|
+
return PLATFORMS.includes(value);
|
|
2631
|
+
}
|
|
2632
|
+
function isAgentHost(value) {
|
|
2633
|
+
return AGENT_HOSTS.includes(value);
|
|
2634
|
+
}
|
|
2635
|
+
|
|
2636
|
+
// src/skill.ts
|
|
2637
|
+
import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2638
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
2639
|
+
|
|
2640
|
+
// src/tools.ts
|
|
2641
|
+
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
2642
|
+
var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
|
|
2643
|
+
var TOOLS = [
|
|
2644
|
+
{
|
|
2645
|
+
name: "setup",
|
|
2646
|
+
usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
2647
|
+
summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
|
|
2648
|
+
when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
|
|
2649
|
+
network: true,
|
|
2650
|
+
needsKey: true,
|
|
2651
|
+
writes: ["speclayer.json", "speclayer.local.json", ".gitignore (one line, when inside a git repo)", "<outDir>/"],
|
|
2652
|
+
exits: OK_OR_ERROR
|
|
2653
|
+
},
|
|
2654
|
+
{
|
|
2655
|
+
name: "init",
|
|
2656
|
+
usage: "spec-layer init --id lib_... [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
2657
|
+
summary: "Writes speclayer.json so later commands need no flags. Stores no key and reaches no server.",
|
|
2658
|
+
when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
|
|
2659
|
+
network: false,
|
|
2660
|
+
needsKey: false,
|
|
2661
|
+
writes: ["speclayer.json"],
|
|
2662
|
+
exits: { "0": "success", "1": "usage error" }
|
|
2663
|
+
},
|
|
2664
|
+
{
|
|
2665
|
+
name: "pull",
|
|
2666
|
+
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
2667
|
+
summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
|
|
2668
|
+
when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block.",
|
|
2669
|
+
network: true,
|
|
2670
|
+
needsKey: true,
|
|
2671
|
+
writes: ["<outDir>/"],
|
|
2672
|
+
exits: OK_OR_ERROR
|
|
2673
|
+
},
|
|
2674
|
+
{
|
|
2675
|
+
name: "status",
|
|
2676
|
+
usage: "spec-layer status [--id lib_...] [--key sl_...] [--out DIR]",
|
|
2677
|
+
summary: "Checks whether the local pull is current without writing anything.",
|
|
2678
|
+
when: "Before reading the pulled files, or in CI; exit 2 means run pull.",
|
|
2679
|
+
network: true,
|
|
2680
|
+
needsKey: true,
|
|
2681
|
+
writes: [],
|
|
2682
|
+
exits: { "0": "up to date", "1": "usage error, bad key or id, or a network or server failure", "2": "behind, or no local pull yet" }
|
|
2683
|
+
},
|
|
2684
|
+
{
|
|
2685
|
+
name: "list",
|
|
2686
|
+
usage: "spec-layer list [--out DIR]",
|
|
2687
|
+
summary: 'Lists every artifact in the last pull with its file path, or "not written" when the selection skipped it.',
|
|
2688
|
+
when: "To learn which components the library documents and where each file is.",
|
|
2689
|
+
network: false,
|
|
2690
|
+
needsKey: false,
|
|
2691
|
+
writes: [],
|
|
2692
|
+
exits: LOCAL_ONLY
|
|
2693
|
+
},
|
|
2694
|
+
{
|
|
2695
|
+
name: "show",
|
|
2696
|
+
usage: "spec-layer show foundation | component NAME [--canonical] [--out DIR]",
|
|
2697
|
+
summary: "Prints one artifact to stdout: the Foundation DTCG document, or one component's AI YAML; --canonical prints the v5 JSON.",
|
|
2698
|
+
when: "To read one component or the token document without opening files; it pipes cleanly.",
|
|
2699
|
+
network: false,
|
|
2700
|
+
needsKey: false,
|
|
2701
|
+
writes: [],
|
|
2702
|
+
exits: LOCAL_ONLY
|
|
2703
|
+
},
|
|
2704
|
+
{
|
|
2705
|
+
name: "tools",
|
|
2706
|
+
usage: "spec-layer tools [--json]",
|
|
2707
|
+
summary: "Prints this list of commands, with what each reaches and writes.",
|
|
2708
|
+
when: "A coding agent deciding which command to run; --json is stable for machines.",
|
|
2709
|
+
network: false,
|
|
2710
|
+
needsKey: false,
|
|
2711
|
+
writes: [],
|
|
2712
|
+
exits: { "0": "success" }
|
|
2713
|
+
},
|
|
2714
|
+
{
|
|
2715
|
+
name: "skill",
|
|
2716
|
+
usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter] [--json] [--out DIR]",
|
|
2717
|
+
summary: "Prints a guide for a coding agent, adapted to this repository's stack and to the last pull; --install writes it where the agent reads instructions.",
|
|
2718
|
+
when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
|
|
2719
|
+
network: false,
|
|
2720
|
+
needsKey: false,
|
|
2721
|
+
writes: ["agent instruction files (only with --install; each path is printed)"],
|
|
2722
|
+
exits: { "0": "success", "1": "usage error, or a file could not be written" }
|
|
2723
|
+
}
|
|
2724
|
+
];
|
|
2725
|
+
var GLOBAL_FLAGS = [
|
|
2726
|
+
{ flag: "--api URL", summary: "Override the API origin (default https://api.spec-layer.com). Also SPEC_LAYER_API." },
|
|
2727
|
+
{ flag: "--out DIR", summary: "Output directory (default .speclayer, or the outDir in speclayer.json)." }
|
|
2728
|
+
];
|
|
2729
|
+
var KEY_RESOLUTION = "The pull key resolves from --key, then SPEC_LAYER_KEY, then speclayer.local.json written by setup. No command ever prints it.";
|
|
2730
|
+
function toolsText() {
|
|
2731
|
+
const lines = ["spec-layer commands", ""];
|
|
2732
|
+
for (const tool of TOOLS) {
|
|
2733
|
+
lines.push(tool.usage);
|
|
2734
|
+
lines.push(` ${tool.summary}`);
|
|
2735
|
+
lines.push(` When: ${tool.when}`);
|
|
2736
|
+
lines.push(` Network: ${tool.network ? "yes" : "no"}. Key: ${tool.needsKey ? "required" : "not needed"}. Writes: ${tool.writes.length ? tool.writes.join(", ") : "nothing"}.`);
|
|
2737
|
+
lines.push(` Exits: ${Object.entries(tool.exits).map(([code2, meaning]) => `${code2} ${meaning}`).join("; ")}.`);
|
|
2738
|
+
lines.push("");
|
|
2739
|
+
}
|
|
2740
|
+
lines.push("Flags every command accepts where they apply:");
|
|
2741
|
+
for (const f of GLOBAL_FLAGS) lines.push(` ${f.flag} ${f.summary}`);
|
|
2742
|
+
lines.push("");
|
|
2743
|
+
lines.push(KEY_RESOLUTION);
|
|
2744
|
+
return lines.join("\n");
|
|
2745
|
+
}
|
|
2746
|
+
function toolsJson(version) {
|
|
2747
|
+
return `${JSON.stringify({
|
|
2748
|
+
cli: "spec-layer",
|
|
2749
|
+
version,
|
|
2750
|
+
tools: TOOLS.map((t) => ({ ...t })),
|
|
2751
|
+
flags: GLOBAL_FLAGS,
|
|
2752
|
+
key_resolution: KEY_RESOLUTION
|
|
2753
|
+
}, null, 2)}
|
|
2754
|
+
`;
|
|
2755
|
+
}
|
|
2756
|
+
|
|
2757
|
+
// src/skill.ts
|
|
2758
|
+
var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
|
|
2759
|
+
function countNumberTokens(tree) {
|
|
2760
|
+
if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return 0;
|
|
2761
|
+
const record = tree;
|
|
2762
|
+
if (record.$type === "number" && "$value" in record) return 1;
|
|
2763
|
+
let n = 0;
|
|
2764
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2765
|
+
if (key.startsWith("$")) continue;
|
|
2766
|
+
n += countNumberTokens(value);
|
|
2767
|
+
}
|
|
2768
|
+
return n;
|
|
2769
|
+
}
|
|
2770
|
+
function readJson(path) {
|
|
2771
|
+
if (!existsSync6(path)) return null;
|
|
2772
|
+
try {
|
|
2773
|
+
return JSON.parse(readFileSync6(path, "utf8"));
|
|
2774
|
+
} catch {
|
|
2775
|
+
return null;
|
|
2776
|
+
}
|
|
2777
|
+
}
|
|
2778
|
+
function summarizePull(cwd, outDir, manifest) {
|
|
2779
|
+
if (!manifest) return null;
|
|
2780
|
+
const absOut = join6(cwd, outDir);
|
|
2781
|
+
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.aiPath ? `${outDir}/${a.aiPath}` : null }));
|
|
2782
|
+
const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
|
|
2783
|
+
let foundation = null;
|
|
2784
|
+
if (foundationEntry) {
|
|
2785
|
+
const tokensDir = join6(absOut, "tokens");
|
|
2786
|
+
const resolver = readJson(join6(tokensDir, "resolver.json"));
|
|
2787
|
+
const report = readJson(join6(tokensDir, "report.json"));
|
|
2788
|
+
let tokenFiles = [];
|
|
2789
|
+
try {
|
|
2790
|
+
tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
|
|
2791
|
+
} catch {
|
|
2792
|
+
tokenFiles = [];
|
|
2793
|
+
}
|
|
2794
|
+
let unitlessNumbers = 0;
|
|
2795
|
+
for (const file of tokenFiles) {
|
|
2796
|
+
if (file.startsWith("styles.")) continue;
|
|
2797
|
+
unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
|
|
2798
|
+
}
|
|
2799
|
+
const reportCounts = {};
|
|
2800
|
+
if (Array.isArray(report)) {
|
|
2801
|
+
for (const entry2 of report) {
|
|
2802
|
+
if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
foundation = {
|
|
2806
|
+
written: foundationEntry.aiPath !== null && resolver !== null,
|
|
2807
|
+
sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
|
|
2808
|
+
modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
|
|
2809
|
+
name,
|
|
2810
|
+
contexts: Object.keys(m.contexts ?? {}),
|
|
2811
|
+
default: m.default ?? null
|
|
2812
|
+
})) : [],
|
|
2813
|
+
tokenFiles,
|
|
2814
|
+
unitlessNumbers,
|
|
2815
|
+
reportCounts
|
|
2816
|
+
};
|
|
2817
|
+
}
|
|
2818
|
+
return {
|
|
2819
|
+
outDir,
|
|
2820
|
+
libraryId: manifest.libraryId,
|
|
2821
|
+
publishedAt: manifest.publishedAt,
|
|
2822
|
+
pluginVersion: manifest.pluginVersion,
|
|
2823
|
+
components,
|
|
2824
|
+
foundation
|
|
2825
|
+
};
|
|
2826
|
+
}
|
|
2827
|
+
var code = (s) => `\`${s}\``;
|
|
2828
|
+
function stackSection(input) {
|
|
2829
|
+
const { profile, platforms, platformSource, pull } = input;
|
|
2830
|
+
const lines = ["## This codebase", ""];
|
|
2831
|
+
if (profile.evidence.length === 0) {
|
|
2832
|
+
lines.push(
|
|
2833
|
+
"Nothing at the root of this directory identified a language, framework, or platform. "
|
|
2834
|
+
);
|
|
2835
|
+
} else {
|
|
2836
|
+
lines.push("Detected from the repository root (the file that carries each signal is named, and nothing deeper was read):", "");
|
|
2837
|
+
for (const e of profile.evidence) lines.push(`- ${e.signal} (${code(e.file)})`);
|
|
2838
|
+
lines.push("");
|
|
2839
|
+
const facts = [];
|
|
2840
|
+
if (profile.languages.length) facts.push(`Languages: ${profile.languages.join(", ")}.`);
|
|
2841
|
+
if (profile.frameworks.length) facts.push(`Frameworks: ${profile.frameworks.join(", ")}.`);
|
|
2842
|
+
if (profile.tokenTools.length) facts.push(`Token tooling: ${profile.tokenTools.join(", ")}.`);
|
|
2843
|
+
if (facts.length) lines.push(facts.join(" "), "");
|
|
2844
|
+
}
|
|
2845
|
+
if (platformSource === "none") {
|
|
2846
|
+
lines.push(
|
|
2847
|
+
`No target platform was detected, so the token advice below is generic. Re-run ${code("spec-layer skill --platform web|ios|android|flutter")} to write it for a platform, or pass ` + code("--install") + " with the same flag to update the installed copy.",
|
|
2848
|
+
""
|
|
2849
|
+
);
|
|
2850
|
+
} else {
|
|
2851
|
+
const label = platformSource === "flag" ? "chosen with --platform" : "detected";
|
|
2852
|
+
lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
|
|
2853
|
+
}
|
|
2854
|
+
for (const platform of platforms) {
|
|
2855
|
+
const key = CODE_SYNTAX_KEY[platform];
|
|
2856
|
+
const tokensDir = `${input.outDir}/tokens/`;
|
|
2857
|
+
if (platform === "web") {
|
|
2858
|
+
lines.push("### Web", "");
|
|
2859
|
+
lines.push(
|
|
2860
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code("code_syntax.WEB")}, when the designer declared one in Figma. Use that identifier as the CSS custom property or theme key. When a token has no WEB entry, derive nothing: use the DTCG path as it appears in the token file (for example ${code("{Collection.group.name}")}) and say in your change that the code name is not declared in Figma.`
|
|
2861
|
+
);
|
|
2862
|
+
lines.push("");
|
|
2863
|
+
if (profile.tokenTools.includes("tailwind")) {
|
|
2864
|
+
lines.push(
|
|
2865
|
+
`Tailwind is present (${code("tailwindcss")}). Map DTCG ${code("color")} tokens to the theme's color scale and ${code("dimension")} tokens to spacing, radius, or font size by the collection and group they sit in. Keep the mapping in one place and reference token paths, not copied values, so a republish moves the code with it.`,
|
|
2866
|
+
""
|
|
2867
|
+
);
|
|
2868
|
+
}
|
|
2869
|
+
if (profile.tokenTools.includes("style-dictionary")) {
|
|
2870
|
+
const major = profile.styleDictionaryMajor;
|
|
2871
|
+
lines.push(
|
|
2872
|
+
`Style Dictionary is present${major !== null ? ` (major version ${major} in package.json)` : ""}. Point it at ${code(tokensDir)} and load the files ${code("resolver.json")} names for the mode you build. Exclude ${code("spec-layer.meta.json")} and ${code("report.json")} from token globs; they are not token files.`
|
|
2873
|
+
);
|
|
2874
|
+
if (major !== null && major < 5) {
|
|
2875
|
+
lines.push(
|
|
2876
|
+
"",
|
|
2877
|
+
`Style Dictionary ${major} reads the string value forms, not the 2025.10 object forms. Set ${code('"dtcg": { "values": "legacy" }')} in ${code("speclayer.json")} and run ${code("spec-layer pull")}; the change re-projects tokens/ without a republish.`
|
|
2878
|
+
);
|
|
2879
|
+
}
|
|
2880
|
+
lines.push("");
|
|
2881
|
+
}
|
|
2882
|
+
} else if (platform === "ios") {
|
|
2883
|
+
lines.push("### iOS", "");
|
|
2884
|
+
lines.push(
|
|
2885
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Swift symbol. Colors arrive as hex strings or DTCG color objects with RGBA components; dimensions carry an explicit px or rem unit. A modifier with more than one context (for example a light and a dark mode) maps to a color-scheme-dependent value; a set without modes is a constant. Do not invent a dark variant for a collection that has one mode.`,
|
|
2886
|
+
""
|
|
2887
|
+
);
|
|
2888
|
+
} else if (platform === "android") {
|
|
2889
|
+
lines.push("### Android", "");
|
|
2890
|
+
lines.push(
|
|
2891
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Kotlin or resource name. Dimensions carry an explicit px or rem unit and no density assumption; a value is only dp when your own convention says so, and that convention belongs in your code, not in the token file. Modes map to resource qualifiers or a Compose theme switch.`,
|
|
2892
|
+
""
|
|
2893
|
+
);
|
|
2894
|
+
} else {
|
|
2895
|
+
lines.push("### Flutter", "");
|
|
2896
|
+
lines.push(
|
|
2897
|
+
`Figma declares no code syntax for Flutter, so no identifier is provided for Dart. Name symbols after the DTCG path (for example ${code("Collection.group.name")} becomes a nested class or a camelCase constant) and keep the path in a comment so the source token stays traceable. Modes map to theme variants.`,
|
|
2898
|
+
""
|
|
2899
|
+
);
|
|
2900
|
+
}
|
|
2901
|
+
}
|
|
2902
|
+
if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
|
|
2903
|
+
const n = pull.foundation.unitlessNumbers;
|
|
2904
|
+
lines.push(
|
|
2905
|
+
`${n} token${n === 1 ? " is" : "s are"} exported as ${code('$type: "number"')} because the Figma scopes state no unit. If your code needs them as px or rem, declare it in ${code("speclayer.json")}: ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')}, then run ${code("spec-layer pull")}. Nothing is inferred from a name; an override that contradicts a stated scope is ignored and listed in ${code("report.json")}.`,
|
|
2906
|
+
""
|
|
2907
|
+
);
|
|
2908
|
+
}
|
|
2909
|
+
return lines;
|
|
2910
|
+
}
|
|
2911
|
+
function pullSection(input) {
|
|
2912
|
+
const { pull, outDir } = input;
|
|
2913
|
+
const lines = ["## What is on disk", ""];
|
|
2914
|
+
if (!pull) {
|
|
2915
|
+
lines.push(
|
|
2916
|
+
`No pull has been made in this directory yet, so nothing under ${code(outDir + "/")} can be described. Run ${code("npx spec-layer pull")} (or the setup command from the plugin if there is no ${code("speclayer.json")}), then ${code("npx spec-layer skill --install")} again to list the components and token collections here.`,
|
|
2917
|
+
""
|
|
2918
|
+
);
|
|
2919
|
+
return lines;
|
|
2920
|
+
}
|
|
2921
|
+
lines.push(
|
|
2922
|
+
`Library ${code(pull.libraryId)}, published ${pull.publishedAt}${pull.pluginVersion ? ` by plugin ${pull.pluginVersion}` : ""}. Run ${code("npx spec-layer status")} first; exit code 2 means a newer publish exists and ${code("npx spec-layer pull")} fetches it.`,
|
|
2923
|
+
""
|
|
2924
|
+
);
|
|
2925
|
+
lines.push(`- ${code(`${outDir}/manifest.json`)}: every artifact with its content hash and file path.`);
|
|
2926
|
+
lines.push(`- ${code(`${outDir}/bundle.json`)}: the whole published library, including the canonical v5 JSON of every artifact.`);
|
|
2927
|
+
if (pull.foundation) {
|
|
2928
|
+
if (pull.foundation.written) {
|
|
2929
|
+
lines.push(`- ${code(`${outDir}/tokens/`)}: the Foundation as Design Tokens Format Module 2025.10 files.`);
|
|
2930
|
+
lines.push(` - ${code("resolver.json")}: sets, modifiers, and resolution order. Start here.`);
|
|
2931
|
+
lines.push(` - ${code("spec-layer.meta.json")}: Figma ids, scopes, publication, and ${code("code_syntax")} per DTCG path.`);
|
|
2932
|
+
lines.push(` - ${code("report.json")}: what the format could not express, with reasons. Never fill these gaps with a guess.`);
|
|
2933
|
+
for (const f of pull.foundation.tokenFiles) lines.push(` - ${code(f)}`);
|
|
2934
|
+
} else {
|
|
2935
|
+
lines.push(`- Foundation: present in the library but not written, because the selection excludes it. ${code("spec-layer show foundation")} still prints it.`);
|
|
2936
|
+
}
|
|
2937
|
+
} else {
|
|
2938
|
+
lines.push("- This library has no Foundation, so there is no tokens/ directory.");
|
|
2939
|
+
}
|
|
2940
|
+
lines.push(`- ${code(`${outDir}/ai/components/`)}: one YAML per component.`);
|
|
2941
|
+
lines.push("");
|
|
2942
|
+
if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
|
|
2943
|
+
lines.push("### Token collections", "");
|
|
2944
|
+
for (const set of pull.foundation.sets) lines.push(`- ${code(set)}: one mode, always applied.`);
|
|
2945
|
+
for (const m of pull.foundation.modifiers) {
|
|
2946
|
+
lines.push(`- ${code(m.name)}: modes ${m.contexts.map(code).join(", ")}${m.default ? `, default ${code(m.default)}` : ""}.`);
|
|
2947
|
+
}
|
|
2948
|
+
lines.push("");
|
|
2949
|
+
const counts = Object.entries(pull.foundation.reportCounts);
|
|
2950
|
+
if (counts.length) {
|
|
2951
|
+
lines.push(
|
|
2952
|
+
`${code("report.json")} lists ${counts.map(([c, n]) => `${n} ${code(c)}`).join(", ")}. Read it before assuming a token is missing.`,
|
|
2953
|
+
""
|
|
2954
|
+
);
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
lines.push("### Components", "");
|
|
2958
|
+
if (pull.components.length === 0) {
|
|
2959
|
+
lines.push("The library documents no components.", "");
|
|
2960
|
+
} else {
|
|
2961
|
+
for (const c of pull.components) {
|
|
2962
|
+
lines.push(c.path ? `- ${c.name}: ${code(c.path)}` : `- ${c.name}: not written (excluded by the selection). ${code(`spec-layer show component "${c.name}"`)} prints it.`);
|
|
2963
|
+
}
|
|
2964
|
+
lines.push("");
|
|
2965
|
+
}
|
|
2966
|
+
return lines;
|
|
2967
|
+
}
|
|
2968
|
+
function commandsSection() {
|
|
2969
|
+
const lines = ["## Commands", ""];
|
|
2970
|
+
const cell = (s) => s.replace(/\|/g, "\\|");
|
|
2971
|
+
lines.push("| Command | What it does | When | Network | Key | Writes |", "|---|---|---|---|---|---|");
|
|
2972
|
+
for (const t of TOOLS) {
|
|
2973
|
+
lines.push(`| ${code(cell(t.usage))} | ${cell(t.summary)} | ${cell(t.when)} | ${t.network ? "yes" : "no"} | ${t.needsKey ? "required" : "no"} | ${t.writes.length ? t.writes.map((w) => code(cell(w))).join(", ") : "nothing"} |`);
|
|
2974
|
+
}
|
|
2975
|
+
lines.push("");
|
|
2976
|
+
lines.push("Exit codes:", "");
|
|
2977
|
+
for (const t of TOOLS) {
|
|
2978
|
+
lines.push(`- ${code(t.name)}: ${Object.entries(t.exits).map(([c, m]) => `${c} = ${m}`).join("; ")}.`);
|
|
2979
|
+
}
|
|
2980
|
+
lines.push("");
|
|
2981
|
+
for (const f of GLOBAL_FLAGS) lines.push(`- ${code(f.flag)}: ${f.summary}`);
|
|
2982
|
+
lines.push("", KEY_RESOLUTION, "");
|
|
2983
|
+
lines.push(`Run ${code("npx --yes spec-layer <command>")} in an unattended session so npx does not stop to ask before downloading the package. ${code("spec-layer tools --json")} prints this table for machines.`, "");
|
|
2984
|
+
return lines;
|
|
2985
|
+
}
|
|
2986
|
+
function buildSkillGuide(input) {
|
|
2987
|
+
const { outDir } = input;
|
|
2988
|
+
const lines = [];
|
|
2989
|
+
lines.push("# Spec Layer: design-system context for this repository", "");
|
|
2990
|
+
lines.push(
|
|
2991
|
+
`The Spec Layer Figma plugin publishes a design system's components, variables, and styles as data. The ${code("spec-layer")} CLI (version ${input.version}) pulls that data into this repository under ${code(outDir + "/")}. Everything in those files is extracted deterministically from Figma and validated against a published schema; no model wrote any of it. Treat it as the source of truth for what the design system contains, and treat anything it does not state as unknown rather than as something to infer.`,
|
|
2992
|
+
""
|
|
2993
|
+
);
|
|
2994
|
+
lines.push("## How to use it", "");
|
|
2995
|
+
lines.push(`1. Run ${code("npx spec-layer status")}. Exit 0 means the local copy is current; exit 2 means run ${code("npx spec-layer pull")} first.`);
|
|
2996
|
+
lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/ai/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
|
|
2997
|
+
lines.push(`3. Working with colors, spacing, type, or effects: start at ${code(`${outDir}/tokens/resolver.json`)}, load the set and mode files it names, and look up ${code("code_syntax")} in ${code("spec-layer.meta.json")} for the name the designer declared for your platform.`);
|
|
2998
|
+
lines.push(`4. Reference tokens by name in code; never paste a resolved value where a token exists. A value the design system does not define is not a token: say so in your change rather than adding one.`);
|
|
2999
|
+
lines.push(`5. An ${code("unbound")} entry is design debt reported from Figma. Do not silently promote it to a token; keep the literal and note that Figma has no binding for it.`);
|
|
3000
|
+
lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory. Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
|
|
3001
|
+
lines.push("");
|
|
3002
|
+
lines.push(...pullSection(input));
|
|
3003
|
+
lines.push(...stackSection(input));
|
|
3004
|
+
lines.push(...commandsSection());
|
|
3005
|
+
lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
|
|
3006
|
+
return `${lines.join("\n")}
|
|
3007
|
+
`;
|
|
3008
|
+
}
|
|
3009
|
+
var SKILL_DESCRIPTION = "Use the design-system context the Spec Layer Figma plugin published into this repository: component variants, states, anatomy, token bindings, and design tokens. Read this before building or changing UI, using tokens, or running the spec-layer CLI.";
|
|
3010
|
+
function installTarget(host) {
|
|
3011
|
+
switch (host) {
|
|
3012
|
+
case "claude":
|
|
3013
|
+
return { host, path: ".claude/skills/spec-layer/SKILL.md", mode: "file" };
|
|
3014
|
+
case "cursor":
|
|
3015
|
+
return { host, path: ".cursor/rules/spec-layer.mdc", mode: "file" };
|
|
3016
|
+
case "copilot":
|
|
3017
|
+
return { host, path: ".github/instructions/spec-layer.instructions.md", mode: "file" };
|
|
3018
|
+
case "windsurf":
|
|
3019
|
+
return { host, path: ".windsurf/rules/spec-layer.md", mode: "file" };
|
|
3020
|
+
case "gemini":
|
|
3021
|
+
return { host, path: "GEMINI.md", mode: "block" };
|
|
3022
|
+
case "agents-md":
|
|
3023
|
+
return { host, path: "AGENTS.md", mode: "block" };
|
|
3024
|
+
}
|
|
3025
|
+
}
|
|
3026
|
+
function renderForHost(host, guide) {
|
|
3027
|
+
const yamlString = (s) => JSON.stringify(s);
|
|
3028
|
+
switch (host) {
|
|
3029
|
+
case "claude":
|
|
3030
|
+
return `---
|
|
3031
|
+
name: spec-layer
|
|
3032
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3033
|
+
---
|
|
3034
|
+
|
|
3035
|
+
${guide}`;
|
|
3036
|
+
case "cursor":
|
|
3037
|
+
return `---
|
|
3038
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3039
|
+
alwaysApply: false
|
|
3040
|
+
---
|
|
3041
|
+
|
|
3042
|
+
${guide}`;
|
|
3043
|
+
case "copilot":
|
|
3044
|
+
return `---
|
|
3045
|
+
applyTo: "**"
|
|
3046
|
+
---
|
|
3047
|
+
|
|
3048
|
+
${guide}`;
|
|
3049
|
+
case "windsurf":
|
|
3050
|
+
return `---
|
|
3051
|
+
trigger: model_decision
|
|
3052
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3053
|
+
---
|
|
3054
|
+
|
|
3055
|
+
${guide}`;
|
|
3056
|
+
case "gemini":
|
|
3057
|
+
case "agents-md":
|
|
3058
|
+
return guide;
|
|
3059
|
+
}
|
|
3060
|
+
}
|
|
3061
|
+
var BLOCK_BEGIN = "<!-- spec-layer:begin -->";
|
|
3062
|
+
var BLOCK_END = "<!-- spec-layer:end -->";
|
|
3063
|
+
function upsertBlock(existing, guide) {
|
|
3064
|
+
const block = `${BLOCK_BEGIN}
|
|
3065
|
+
${guide.trimEnd()}
|
|
3066
|
+
${BLOCK_END}
|
|
3067
|
+
`;
|
|
3068
|
+
if (existing === null) return block;
|
|
3069
|
+
const begin = existing.indexOf(BLOCK_BEGIN);
|
|
3070
|
+
const end = existing.indexOf(BLOCK_END);
|
|
3071
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
3072
|
+
const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
|
|
3073
|
+
return `${existing.slice(0, begin)}${block}${after}`;
|
|
3074
|
+
}
|
|
3075
|
+
const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3076
|
+
return `${existing}${sep}${block}`;
|
|
3077
|
+
}
|
|
3078
|
+
function installSkill(cwd, host, guide) {
|
|
3079
|
+
const target = installTarget(host);
|
|
3080
|
+
const abs = join6(cwd, target.path);
|
|
3081
|
+
const existing = existsSync6(abs) ? readFileSync6(abs, "utf8") : null;
|
|
3082
|
+
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
3083
|
+
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
3084
|
+
mkdirSync2(dirname3(abs), { recursive: true });
|
|
3085
|
+
writeFileSync5(abs, next);
|
|
3086
|
+
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
3087
|
+
}
|
|
3088
|
+
|
|
3089
|
+
// src/version.ts
|
|
3090
|
+
import { readFileSync as readFileSync7 } from "node:fs";
|
|
3091
|
+
function cliVersion() {
|
|
3092
|
+
try {
|
|
3093
|
+
const parsed = JSON.parse(readFileSync7(new URL("../package.json", import.meta.url), "utf8"));
|
|
3094
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
3095
|
+
} catch {
|
|
3096
|
+
return "unknown";
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
|
|
1161
3100
|
// src/commands.ts
|
|
1162
3101
|
var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
|
|
1163
3102
|
function manifestReader() {
|
|
@@ -1167,9 +3106,17 @@ function manifestReader() {
|
|
|
1167
3106
|
return cache.get(outDir) ?? null;
|
|
1168
3107
|
};
|
|
1169
3108
|
}
|
|
1170
|
-
function
|
|
1171
|
-
const
|
|
1172
|
-
|
|
3109
|
+
function sameOutput(a, b) {
|
|
3110
|
+
const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
|
|
3111
|
+
const dtcgKey = (d) => JSON.stringify(sortKeys(d ?? {}));
|
|
3112
|
+
return selectionKey(a.selection) === selectionKey(b.selection) && dtcgKey(a.dtcg) === dtcgKey(b.dtcg);
|
|
3113
|
+
}
|
|
3114
|
+
function sortKeys(value) {
|
|
3115
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
3116
|
+
if (value && typeof value === "object") {
|
|
3117
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, sortKeys(value[k])]));
|
|
3118
|
+
}
|
|
3119
|
+
return value;
|
|
1173
3120
|
}
|
|
1174
3121
|
var errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
1175
3122
|
function runInit(cwd, flags, io2) {
|
|
@@ -1210,7 +3157,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
|
|
|
1210
3157
|
}
|
|
1211
3158
|
function resolvedOutDir(cwd, flags, io2) {
|
|
1212
3159
|
try {
|
|
1213
|
-
return
|
|
3160
|
+
return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
|
|
1214
3161
|
} catch (err) {
|
|
1215
3162
|
io2.err(errorText(err));
|
|
1216
3163
|
return null;
|
|
@@ -1248,7 +3195,13 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
|
|
|
1248
3195
|
}
|
|
1249
3196
|
const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
|
|
1250
3197
|
const keptInclude = include ?? existing?.include ?? null;
|
|
1251
|
-
|
|
3198
|
+
const keptDtcg = existing?.dtcg ?? null;
|
|
3199
|
+
writeConfig(cwd, {
|
|
3200
|
+
libraryId: flags.id,
|
|
3201
|
+
outDir,
|
|
3202
|
+
...keptInclude ? { include: keptInclude } : {},
|
|
3203
|
+
...keptDtcg ? { dtcg: keptDtcg } : {}
|
|
3204
|
+
});
|
|
1252
3205
|
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
|
|
1253
3206
|
const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
|
|
1254
3207
|
switch (ignored.kind) {
|
|
@@ -1287,7 +3240,14 @@ git rm --cached ${ignored.line}`);
|
|
|
1287
3240
|
}
|
|
1288
3241
|
const { replaced } = writeCredentials(cwd, { libraryId: flags.id, key });
|
|
1289
3242
|
io2.out(replaced ? `Replaced the stored key in ${CREDENTIALS_NAME}.` : `Stored the pull key in ${CREDENTIALS_NAME}.`);
|
|
1290
|
-
|
|
3243
|
+
const code2 = await runPull(cwd, { ...flags, key }, env, io2, fetcher);
|
|
3244
|
+
if (code2 !== 0) return code2;
|
|
3245
|
+
const hosts = detectRepo(cwd).agents;
|
|
3246
|
+
io2.out("");
|
|
3247
|
+
io2.out("Next step for a coding agent: npx spec-layer skill --install");
|
|
3248
|
+
io2.out(hosts.length > 0 ? `That writes a guide to the pulled files, adapted to this codebase, to ${hosts.map((h) => installTarget(h).path).join(", ")}.` : `That writes a guide to the pulled files, adapted to this codebase, into ${installTarget("agents-md").path}; --agent ${AGENT_HOSTS.join("|")} chooses where.`);
|
|
3249
|
+
io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
|
|
3250
|
+
return 0;
|
|
1291
3251
|
}
|
|
1292
3252
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
1293
3253
|
const manifestAt = manifestReader();
|
|
@@ -1300,8 +3260,11 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
1300
3260
|
io2.err(errorText(err));
|
|
1301
3261
|
return 1;
|
|
1302
3262
|
}
|
|
1303
|
-
const manifest = manifestAt(
|
|
1304
|
-
const etag = manifest &&
|
|
3263
|
+
const manifest = manifestAt(join7(cwd, opts.outDir));
|
|
3264
|
+
const etag = manifest && sameOutput(
|
|
3265
|
+
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
|
|
3266
|
+
{ selection, dtcg: opts.dtcg }
|
|
3267
|
+
) ? manifest.bundleHash : void 0;
|
|
1305
3268
|
const result = await fetchBundle({
|
|
1306
3269
|
api: opts.api,
|
|
1307
3270
|
libraryId: opts.libraryId,
|
|
@@ -1322,14 +3285,15 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
1322
3285
|
const bundle = parseBundle(result.raw);
|
|
1323
3286
|
const selected = selectComponents(bundle, selection);
|
|
1324
3287
|
written = writeBundleFiles({
|
|
1325
|
-
outDir:
|
|
3288
|
+
outDir: join7(cwd, opts.outDir),
|
|
1326
3289
|
cwd,
|
|
1327
3290
|
raw: result.raw,
|
|
1328
3291
|
bundle,
|
|
1329
3292
|
selection,
|
|
1330
3293
|
libraryId: opts.libraryId,
|
|
1331
3294
|
publishedAt: result.publishedAt,
|
|
1332
|
-
bundleHash: result.bundleHash
|
|
3295
|
+
bundleHash: result.bundleHash,
|
|
3296
|
+
dtcg: opts.dtcg
|
|
1333
3297
|
});
|
|
1334
3298
|
io2.out(
|
|
1335
3299
|
`Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
|
|
@@ -1345,7 +3309,7 @@ async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
|
1345
3309
|
const manifestAt = manifestReader();
|
|
1346
3310
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
1347
3311
|
if (!opts) return 1;
|
|
1348
|
-
const manifest = manifestAt(
|
|
3312
|
+
const manifest = manifestAt(join7(cwd, opts.outDir));
|
|
1349
3313
|
if (!manifest) {
|
|
1350
3314
|
io2.err(NO_LOCAL_PULL);
|
|
1351
3315
|
return 2;
|
|
@@ -1431,6 +3395,90 @@ Available: ${available || "none"}.`);
|
|
|
1431
3395
|
` : entry2.ai);
|
|
1432
3396
|
return 0;
|
|
1433
3397
|
}
|
|
3398
|
+
function runTools(flags, io2) {
|
|
3399
|
+
if (flags.json) io2.write(toolsJson(cliVersion()));
|
|
3400
|
+
else io2.out(toolsText());
|
|
3401
|
+
return 0;
|
|
3402
|
+
}
|
|
3403
|
+
function collectSkillInput(cwd, flags, io2) {
|
|
3404
|
+
let config = null;
|
|
3405
|
+
try {
|
|
3406
|
+
config = readConfig(cwd);
|
|
3407
|
+
} catch (err) {
|
|
3408
|
+
io2.err(errorText(err));
|
|
3409
|
+
return null;
|
|
3410
|
+
}
|
|
3411
|
+
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
3412
|
+
const profile = detectRepo(cwd);
|
|
3413
|
+
let platforms;
|
|
3414
|
+
let platformSource;
|
|
3415
|
+
if (flags.platform !== void 0) {
|
|
3416
|
+
if (!isPlatform(flags.platform)) {
|
|
3417
|
+
io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${flags.platform}".`);
|
|
3418
|
+
return null;
|
|
3419
|
+
}
|
|
3420
|
+
platforms = [flags.platform];
|
|
3421
|
+
platformSource = "flag";
|
|
3422
|
+
} else {
|
|
3423
|
+
platforms = profile.platforms;
|
|
3424
|
+
platformSource = platforms.length > 0 ? "detected" : "none";
|
|
3425
|
+
}
|
|
3426
|
+
const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
|
|
3427
|
+
return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
|
|
3428
|
+
}
|
|
3429
|
+
function skillHosts(flags, input, io2) {
|
|
3430
|
+
const named = flags.agent ?? [];
|
|
3431
|
+
if (named.length > 0) {
|
|
3432
|
+
const hosts = [];
|
|
3433
|
+
for (const value of named) {
|
|
3434
|
+
if (!isAgentHost(value)) {
|
|
3435
|
+
io2.err(`--agent takes ${AGENT_HOSTS.join(", ")}, not "${value}".`);
|
|
3436
|
+
return null;
|
|
3437
|
+
}
|
|
3438
|
+
if (!hosts.includes(value)) hosts.push(value);
|
|
3439
|
+
}
|
|
3440
|
+
return hosts;
|
|
3441
|
+
}
|
|
3442
|
+
return input.profile.agents.length > 0 ? input.profile.agents : ["agents-md"];
|
|
3443
|
+
}
|
|
3444
|
+
function runSkill(cwd, flags, io2) {
|
|
3445
|
+
const input = collectSkillInput(cwd, flags, io2);
|
|
3446
|
+
if (!input) return 1;
|
|
3447
|
+
const hosts = skillHosts(flags, input, io2);
|
|
3448
|
+
if (!hosts) return 1;
|
|
3449
|
+
if (flags.json) {
|
|
3450
|
+
io2.write(`${JSON.stringify({
|
|
3451
|
+
cli_version: input.version,
|
|
3452
|
+
detected: input.profile,
|
|
3453
|
+
platforms: input.platforms,
|
|
3454
|
+
platform_source: input.platformSource,
|
|
3455
|
+
pull: input.pull,
|
|
3456
|
+
install_targets: hosts.map((h) => installTarget(h))
|
|
3457
|
+
}, null, 2)}
|
|
3458
|
+
`);
|
|
3459
|
+
return 0;
|
|
3460
|
+
}
|
|
3461
|
+
const guide = buildSkillGuide(input);
|
|
3462
|
+
if (!flags.install) {
|
|
3463
|
+
io2.write(guide);
|
|
3464
|
+
return 0;
|
|
3465
|
+
}
|
|
3466
|
+
const chosen = flags.agent && flags.agent.length > 0 ? "named with --agent" : input.profile.agents.length > 0 ? "detected in this repository" : "the default when no agent is detected";
|
|
3467
|
+
for (const host of hosts) {
|
|
3468
|
+
let outcome;
|
|
3469
|
+
try {
|
|
3470
|
+
outcome = installSkill(cwd, host, guide);
|
|
3471
|
+
} catch (err) {
|
|
3472
|
+
io2.err(`Could not write ${installTarget(host).path}: ${errorText(err)}`);
|
|
3473
|
+
return 1;
|
|
3474
|
+
}
|
|
3475
|
+
const verb = outcome.result === "created" ? "Wrote" : outcome.result === "updated" ? "Updated" : "Unchanged:";
|
|
3476
|
+
io2.out(`${verb} ${outcome.path} (${host}, ${chosen}).`);
|
|
3477
|
+
}
|
|
3478
|
+
if (!input.pull) io2.out(`No local pull yet, so the guide lists no components. Run spec-layer pull, then spec-layer skill --install again.`);
|
|
3479
|
+
if (input.platformSource === "none") io2.out(`No target platform detected. Pass --platform ${PLATFORMS.join("|")} to write platform-specific token advice.`);
|
|
3480
|
+
return 0;
|
|
3481
|
+
}
|
|
1434
3482
|
|
|
1435
3483
|
// src/cli.ts
|
|
1436
3484
|
var USAGE = `spec-layer <command>
|
|
@@ -1440,11 +3488,15 @@ Commands:
|
|
|
1440
3488
|
store the key, then pull
|
|
1441
3489
|
init --id lib_... [--out DIR] [selection] write speclayer.json
|
|
1442
3490
|
pull [--id lib_...] [--key sl_...] [selection]
|
|
1443
|
-
fetch the library into DIR (default .speclayer)
|
|
3491
|
+
fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
|
|
1444
3492
|
status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
|
|
1445
3493
|
list list every artifact in the last pull
|
|
1446
3494
|
show foundation | component NAME [--canonical]
|
|
1447
|
-
print one artifact
|
|
3495
|
+
print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
|
|
3496
|
+
tools [--json] list every command with what it reaches and writes
|
|
3497
|
+
skill [--install] [--agent HOST]... [--platform P] [--json]
|
|
3498
|
+
print a guide for a coding agent, adapted to this repo and the last pull;
|
|
3499
|
+
--install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
|
|
1448
3500
|
|
|
1449
3501
|
Selection (setup, pull and init; flags replace the include block in speclayer.json):
|
|
1450
3502
|
--only foundation | components write just the foundation, or just components
|
|
@@ -1473,7 +3525,11 @@ async function main() {
|
|
|
1473
3525
|
api: { type: "string" },
|
|
1474
3526
|
only: { type: "string" },
|
|
1475
3527
|
component: { type: "string", multiple: true },
|
|
1476
|
-
canonical: { type: "boolean" }
|
|
3528
|
+
canonical: { type: "boolean" },
|
|
3529
|
+
json: { type: "boolean" },
|
|
3530
|
+
install: { type: "boolean" },
|
|
3531
|
+
agent: { type: "string", multiple: true },
|
|
3532
|
+
platform: { type: "string" }
|
|
1477
3533
|
}
|
|
1478
3534
|
}));
|
|
1479
3535
|
} catch {
|
|
@@ -1489,6 +3545,8 @@ async function main() {
|
|
|
1489
3545
|
if (command === "status") return await runStatus(cwd, values, process.env, io);
|
|
1490
3546
|
if (command === "list") return runList(cwd, values, io);
|
|
1491
3547
|
if (command === "show") return runShow(cwd, values, positionals.slice(1), io);
|
|
3548
|
+
if (command === "tools") return runTools(values, io);
|
|
3549
|
+
if (command === "skill") return runSkill(cwd, values, io);
|
|
1492
3550
|
io.err(USAGE);
|
|
1493
3551
|
return 1;
|
|
1494
3552
|
} catch (err) {
|
|
@@ -1496,6 +3554,10 @@ async function main() {
|
|
|
1496
3554
|
return 1;
|
|
1497
3555
|
}
|
|
1498
3556
|
}
|
|
3557
|
+
process.stdout.on("error", (err) => {
|
|
3558
|
+
if (err.code === "EPIPE") process.exit(process.exitCode ?? 0);
|
|
3559
|
+
throw err;
|
|
3560
|
+
});
|
|
1499
3561
|
process.exitCode = await main();
|
|
1500
3562
|
/*! Bundled license information:
|
|
1501
3563
|
|