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