spec-layer 0.7.0 → 0.9.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 +4 -1
- package/dist/cli.js +776 -90
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -62,7 +62,10 @@ Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
|
|
|
62
62
|
`ai/components/`, `path` in place of `aiPath` in the manifest, and the
|
|
63
63
|
`outputs` block need 0.6.0 or later. `component-specs/` beside `tokens/`,
|
|
64
64
|
`componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
|
|
65
|
-
need 0.7.0 or later.
|
|
65
|
+
need 0.7.0 or later. The `census` and `config_hash` blocks inside
|
|
66
|
+
`resolver.json`, and the `transform` and `resolved` fields in
|
|
67
|
+
`spec-layer.meta.json`, need 0.8.2 or later; an earlier version pulls the same
|
|
68
|
+
files without those fields.
|
|
66
69
|
|
|
67
70
|
## Commands
|
|
68
71
|
|
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 { existsSync as existsSync8 } from "node:fs";
|
|
562
|
+
import { existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
|
|
563
563
|
import { join as join8, resolve as resolve5 } from "node:path";
|
|
564
564
|
|
|
565
565
|
// ../extractor/src/statesMatrix.ts
|
|
@@ -814,8 +814,37 @@ var SUPPORTED_TOKEN_TYPES = [
|
|
|
814
814
|
var SUPPORTED_VALUE_KINDS = ["literal", "alias", "missing"];
|
|
815
815
|
var SUPPORTED_DURATION_UNITS = ["ms", "s"];
|
|
816
816
|
|
|
817
|
+
// ../extractor/src/v5/units.ts
|
|
818
|
+
var UNIT_BY_SCOPE = {
|
|
819
|
+
WIDTH_HEIGHT: "px",
|
|
820
|
+
CORNER_RADIUS: "px",
|
|
821
|
+
GAP: "px",
|
|
822
|
+
FONT_SIZE: "px",
|
|
823
|
+
STROKE_FLOAT: "px",
|
|
824
|
+
PARAGRAPH_SPACING: "px",
|
|
825
|
+
PARAGRAPH_INDENT: "px",
|
|
826
|
+
EFFECT_FLOAT: "px",
|
|
827
|
+
FONT_WEIGHT: "number",
|
|
828
|
+
OPACITY: "number"
|
|
829
|
+
};
|
|
830
|
+
function scopesStateUnit(scopes) {
|
|
831
|
+
return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] !== void 0);
|
|
832
|
+
}
|
|
833
|
+
function scopesStateNumber(scopes) {
|
|
834
|
+
return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] === "number");
|
|
835
|
+
}
|
|
836
|
+
|
|
817
837
|
// ../extractor/src/v5/canonical.ts
|
|
818
838
|
var import_js_sha2562 = __toESM(require_sha256(), 1);
|
|
839
|
+
var SCHEMA_VERSION = "5.1.0";
|
|
840
|
+
function canonicalJson(value) {
|
|
841
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
842
|
+
if (value && typeof value === "object") {
|
|
843
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => compareCodeUnits(a, b)).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
|
|
844
|
+
return `{${entries.join(",")}}`;
|
|
845
|
+
}
|
|
846
|
+
return JSON.stringify(value);
|
|
847
|
+
}
|
|
819
848
|
|
|
820
849
|
// ../extractor/src/v5/validate.ts
|
|
821
850
|
var ROOT = "<artifact>";
|
|
@@ -1316,7 +1345,27 @@ function validateLevel1(artifact) {
|
|
|
1316
1345
|
}
|
|
1317
1346
|
}
|
|
1318
1347
|
|
|
1348
|
+
// ../extractor/src/v5/fonts.ts
|
|
1349
|
+
function fontRequirements(artifact) {
|
|
1350
|
+
const byFamily = /* @__PURE__ */ new Map();
|
|
1351
|
+
for (const style of artifact.styles.typography) {
|
|
1352
|
+
const family = style.properties.font_family.resolved;
|
|
1353
|
+
if (family === null || family.type !== "font_family") continue;
|
|
1354
|
+
const bucket = byFamily.get(family.value) ?? { weights: /* @__PURE__ */ new Set(), used: /* @__PURE__ */ new Set() };
|
|
1355
|
+
const weight = style.properties.font_weight.resolved;
|
|
1356
|
+
if (weight !== null && weight.type === "number") bucket.weights.add(weight.value);
|
|
1357
|
+
bucket.used.add(style.name);
|
|
1358
|
+
byFamily.set(family.value, bucket);
|
|
1359
|
+
}
|
|
1360
|
+
return [...byFamily.entries()].sort((a, b) => compareCodeUnits(a[0], b[0])).map(([family, bucket]) => ({
|
|
1361
|
+
family,
|
|
1362
|
+
weights: [...bucket.weights].sort((a, b) => a - b),
|
|
1363
|
+
used_by: [...bucket.used].sort(compareCodeUnits)
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1319
1367
|
// ../extractor/src/v5/dtcg.ts
|
|
1368
|
+
var import_js_sha2563 = __toESM(require_sha256(), 1);
|
|
1320
1369
|
function dtcgSegments(name) {
|
|
1321
1370
|
const segments = [];
|
|
1322
1371
|
const notes = [];
|
|
@@ -1334,6 +1383,9 @@ function dtcgSegments(name) {
|
|
|
1334
1383
|
}
|
|
1335
1384
|
return { segments, notes };
|
|
1336
1385
|
}
|
|
1386
|
+
function dtcgPathOf(collectionName, tokenName) {
|
|
1387
|
+
return [...dtcgSegments(collectionName).segments, ...dtcgSegments(tokenName).segments].join(".");
|
|
1388
|
+
}
|
|
1337
1389
|
function trimDashes(s) {
|
|
1338
1390
|
let start = 0;
|
|
1339
1391
|
let end = s.length;
|
|
@@ -1435,6 +1487,15 @@ function sortTree(value) {
|
|
|
1435
1487
|
const keys = Object.keys(value).sort((a, b) => rank(a) - rank(b) || compareCodeUnits(a, b));
|
|
1436
1488
|
return Object.fromEntries(keys.map((k) => [k, sortTree(value[k])]));
|
|
1437
1489
|
}
|
|
1490
|
+
function recordFact(p, tokenId, mode, transform, resolved2) {
|
|
1491
|
+
let facts = p.factsById.get(tokenId);
|
|
1492
|
+
if (!facts) {
|
|
1493
|
+
facts = { transform: {}, resolved: {} };
|
|
1494
|
+
p.factsById.set(tokenId, facts);
|
|
1495
|
+
}
|
|
1496
|
+
facts.transform[mode] = transform;
|
|
1497
|
+
if (resolved2 !== void 0) facts.resolved[mode] = resolved2;
|
|
1498
|
+
}
|
|
1438
1499
|
function reportOnce(p, entry2) {
|
|
1439
1500
|
const key = JSON.stringify([entry2.code, entry2.path, entry2.mode ?? null, entry2.details]);
|
|
1440
1501
|
if (p.reportKeys.has(key)) return;
|
|
@@ -1495,20 +1556,117 @@ function unitOverrideFor(p, token, collection) {
|
|
|
1495
1556
|
}
|
|
1496
1557
|
return void 0;
|
|
1497
1558
|
}
|
|
1498
|
-
|
|
1499
|
-
|
|
1559
|
+
function reportPathOf(p, token) {
|
|
1560
|
+
const path = p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name;
|
|
1561
|
+
return p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path;
|
|
1562
|
+
}
|
|
1563
|
+
function ownerFor(p, token) {
|
|
1564
|
+
const path = reportPathOf(p, token);
|
|
1565
|
+
return {
|
|
1566
|
+
overrideConflict: (override) => {
|
|
1567
|
+
reportOnce(p, {
|
|
1568
|
+
code: "unit_override_conflicts_with_scope",
|
|
1569
|
+
severity: "warning",
|
|
1570
|
+
path,
|
|
1571
|
+
message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
|
|
1572
|
+
details: { id: token.id, override, scopes: [...token.scopes] }
|
|
1573
|
+
});
|
|
1574
|
+
},
|
|
1575
|
+
// Reported without a mode, like the override conflict above: the evidence
|
|
1576
|
+
// is a fact about the token, not about one of its values, so a token in
|
|
1577
|
+
// three modes earns one entry rather than three.
|
|
1578
|
+
derivedUnit: (evidence) => {
|
|
1579
|
+
reportOnce(p, {
|
|
1580
|
+
code: "unit_derived_from_usage",
|
|
1581
|
+
severity: "info",
|
|
1582
|
+
path,
|
|
1583
|
+
// "its own variable", not "no scope": for `via: 'alias-scope'` a scope
|
|
1584
|
+
// is exactly what stated the unit, and this same sentence goes on to
|
|
1585
|
+
// name it. What is true of both kinds of evidence is that the token's
|
|
1586
|
+
// OWN variable states nothing. The CSS header that points a reader at
|
|
1587
|
+
// this entry says it the same way, for the same reason.
|
|
1588
|
+
message: `This token's own variable states no unit, so ${evidence.unit} was taken from how the library uses it: ${evidence.source} ${evidence.via === "binding" ? "binds it to" : "is scoped"} ${evidence.reason}.`,
|
|
1589
|
+
details: {
|
|
1590
|
+
id: token.id,
|
|
1591
|
+
unit: evidence.unit,
|
|
1592
|
+
via: evidence.via,
|
|
1593
|
+
source: evidence.source,
|
|
1594
|
+
reason: evidence.reason
|
|
1595
|
+
}
|
|
1596
|
+
});
|
|
1597
|
+
}
|
|
1598
|
+
};
|
|
1599
|
+
}
|
|
1600
|
+
function projectedLiteral(p, token, resolved2, owner) {
|
|
1500
1601
|
const collection = p.collectionById.get(token.collection_id);
|
|
1501
1602
|
const override = collection ? unitOverrideFor(p, token, collection) : void 0;
|
|
1502
1603
|
let literal = resolved2;
|
|
1604
|
+
let overrode = false;
|
|
1605
|
+
let derived;
|
|
1503
1606
|
if (override !== void 0 && literal.type === "number") {
|
|
1504
|
-
if (token.scopes
|
|
1505
|
-
else
|
|
1607
|
+
if (scopesStateNumber(token.scopes)) owner?.overrideConflict(override);
|
|
1608
|
+
else {
|
|
1609
|
+
literal = { type: "dimension", number: literal.value, unit: override };
|
|
1610
|
+
overrode = true;
|
|
1611
|
+
}
|
|
1612
|
+
} else if (literal.type === "number" && !scopesStateUnit(token.scopes)) {
|
|
1613
|
+
const evidence = p.derivedUnits.get(token.id);
|
|
1614
|
+
if (evidence !== void 0) {
|
|
1615
|
+
literal = { type: "dimension", number: literal.value, unit: evidence.unit };
|
|
1616
|
+
derived = evidence;
|
|
1617
|
+
owner?.derivedUnit(evidence);
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
const converted2 = dtcgLiteral(literal, token.scopes, p.options.values);
|
|
1621
|
+
if ("omit" in converted2) return { converted: converted2, transform: null };
|
|
1622
|
+
let transform = literalTransform(literal, token.scopes);
|
|
1623
|
+
if (overrode) transform = "number-unit-override";
|
|
1624
|
+
else if (derived !== void 0) transform = "number-unit-usage";
|
|
1625
|
+
return { converted: converted2, transform };
|
|
1626
|
+
}
|
|
1627
|
+
function literalTransform(value, scopes) {
|
|
1628
|
+
switch (value.type) {
|
|
1629
|
+
case "color":
|
|
1630
|
+
return "color";
|
|
1631
|
+
case "dimension":
|
|
1632
|
+
return "dimension";
|
|
1633
|
+
case "duration":
|
|
1634
|
+
return "duration";
|
|
1635
|
+
case "number":
|
|
1636
|
+
return scopes.includes("FONT_WEIGHT") ? "font-weight" : "number";
|
|
1637
|
+
case "cubic_bezier":
|
|
1638
|
+
return "cubic-bezier";
|
|
1639
|
+
case "font_family":
|
|
1640
|
+
return "font-family";
|
|
1641
|
+
case "string":
|
|
1642
|
+
case "boolean":
|
|
1643
|
+
return null;
|
|
1644
|
+
default: {
|
|
1645
|
+
const exhaustive = value;
|
|
1646
|
+
return exhaustive;
|
|
1647
|
+
}
|
|
1506
1648
|
}
|
|
1507
|
-
return dtcgLiteral(literal, token.scopes, p.options.values);
|
|
1508
1649
|
}
|
|
1509
1650
|
function aliasLeafType(p, token, chain, resolved2) {
|
|
1510
1651
|
const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
|
|
1511
|
-
|
|
1652
|
+
const subject = terminal ?? token;
|
|
1653
|
+
return projectedLiteral(p, subject, resolved2, ownerFor(p, subject)).converted;
|
|
1654
|
+
}
|
|
1655
|
+
function terminalOwnType(p, chain) {
|
|
1656
|
+
const hop = chain.length > 0 ? chain[chain.length - 1] : void 0;
|
|
1657
|
+
const terminal = hop ? p.tokenById.get(hop.token_id) : void 0;
|
|
1658
|
+
const value = terminal && hop ? terminal.values[hop.mode_id] : void 0;
|
|
1659
|
+
if (!terminal || !value || value.kind !== "literal") return void 0;
|
|
1660
|
+
return projectedLiteral(p, terminal, value.value, ownerFor(p, terminal)).converted;
|
|
1661
|
+
}
|
|
1662
|
+
function reportAliasTypeMismatch(p, path, targetPath, ownType, targetType) {
|
|
1663
|
+
reportOnce(p, {
|
|
1664
|
+
code: "alias_type_mismatch",
|
|
1665
|
+
severity: "error",
|
|
1666
|
+
path,
|
|
1667
|
+
message: `This token is "${ownType}" but its alias target ${targetPath} is "${targetType}"; a consumer reading the declared type gets a value the target cannot carry.`,
|
|
1668
|
+
details: { target: targetPath, own_type: ownType, target_type: targetType }
|
|
1669
|
+
});
|
|
1512
1670
|
}
|
|
1513
1671
|
function modeLabels(collection) {
|
|
1514
1672
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1547,6 +1705,17 @@ function reportCollectionNameCollisions(p) {
|
|
|
1547
1705
|
function asJson(value) {
|
|
1548
1706
|
return JSON.parse(JSON.stringify(value));
|
|
1549
1707
|
}
|
|
1708
|
+
function transformField(p, token) {
|
|
1709
|
+
const facts = p.factsById.get(token.id);
|
|
1710
|
+
if (!facts) return {};
|
|
1711
|
+
const sorted = (source) => {
|
|
1712
|
+
const keys = Object.keys(source).sort(compareCodeUnits);
|
|
1713
|
+
return keys.length === 0 ? void 0 : Object.fromEntries(keys.map((k) => [k, source[k]]));
|
|
1714
|
+
};
|
|
1715
|
+
const transform = sorted(facts.transform);
|
|
1716
|
+
const resolved2 = sorted(facts.resolved);
|
|
1717
|
+
return { ...transform ? { transform } : {}, ...resolved2 ? { resolved: resolved2 } : {} };
|
|
1718
|
+
}
|
|
1550
1719
|
function metaEntry(p, token, collection) {
|
|
1551
1720
|
const labels = p.modeLabelsById.get(collection.id) ?? modeLabels(collection);
|
|
1552
1721
|
const omitted = p.omittedIds.has(token.id);
|
|
@@ -1559,6 +1728,7 @@ function metaEntry(p, token, collection) {
|
|
|
1559
1728
|
collection_id: token.collection_id,
|
|
1560
1729
|
type: token.type,
|
|
1561
1730
|
scopes: [...token.scopes],
|
|
1731
|
+
...omitted ? {} : transformField(p, token),
|
|
1562
1732
|
...token.code_syntax ? { code_syntax: token.code_syntax } : {},
|
|
1563
1733
|
...token.publication ? { publication: token.publication } : {},
|
|
1564
1734
|
...omitted ? {
|
|
@@ -1857,10 +2027,69 @@ function annotateGroups(p, tree, collection) {
|
|
|
1857
2027
|
}
|
|
1858
2028
|
}
|
|
1859
2029
|
}
|
|
1860
|
-
|
|
2030
|
+
var newAccumulator = () => ({
|
|
2031
|
+
tokens: 0,
|
|
2032
|
+
types: /* @__PURE__ */ new Map(),
|
|
2033
|
+
present: 0,
|
|
2034
|
+
missing: 0,
|
|
2035
|
+
aliases: 0,
|
|
2036
|
+
literals: 0,
|
|
2037
|
+
scopes: /* @__PURE__ */ new Map(),
|
|
2038
|
+
codeSyntaxPresent: 0,
|
|
2039
|
+
codeSyntaxMissing: 0,
|
|
2040
|
+
published: 0,
|
|
2041
|
+
hiddenFromPublishing: 0,
|
|
2042
|
+
unstated: 0,
|
|
2043
|
+
omitted: 0,
|
|
2044
|
+
collided: 0
|
|
2045
|
+
});
|
|
2046
|
+
var bump = (counts, key) => {
|
|
2047
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
2048
|
+
};
|
|
2049
|
+
var histogram = (counts) => Object.fromEntries([...counts.keys()].sort(compareCodeUnits).map((k) => [k, counts.get(k)]));
|
|
2050
|
+
function censusEntry(a) {
|
|
2051
|
+
return {
|
|
2052
|
+
tokens: a.tokens,
|
|
2053
|
+
types: histogram(a.types),
|
|
2054
|
+
descriptions: { present: a.present, missing: a.missing },
|
|
2055
|
+
aliases: a.aliases,
|
|
2056
|
+
literals: a.literals,
|
|
2057
|
+
scopes: histogram(a.scopes),
|
|
2058
|
+
code_syntax: { present: a.codeSyntaxPresent, missing: a.codeSyntaxMissing },
|
|
2059
|
+
publication: {
|
|
2060
|
+
published: a.published,
|
|
2061
|
+
hidden_from_publishing: a.hiddenFromPublishing,
|
|
2062
|
+
unstated: a.unstated
|
|
2063
|
+
},
|
|
2064
|
+
...a.omitted > 0 ? { omitted: a.omitted } : {},
|
|
2065
|
+
...a.collided > 0 ? { collided: a.collided } : {}
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
function styleCensus(tree) {
|
|
2069
|
+
const a = newAccumulator();
|
|
2070
|
+
const walk = (node) => {
|
|
2071
|
+
if (typeof node !== "object" || node === null || Array.isArray(node)) return;
|
|
2072
|
+
const record = node;
|
|
2073
|
+
if ("$value" in record) {
|
|
2074
|
+
a.tokens += 1;
|
|
2075
|
+
bump(a.types, typeof record.$type === "string" ? record.$type : "unknown");
|
|
2076
|
+
if (typeof record.$description === "string" && record.$description.length > 0) a.present += 1;
|
|
2077
|
+
else a.missing += 1;
|
|
2078
|
+
return;
|
|
2079
|
+
}
|
|
2080
|
+
for (const [key, value] of Object.entries(record)) {
|
|
2081
|
+
if (key.startsWith("$")) continue;
|
|
2082
|
+
walk(value);
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
walk(tree);
|
|
2086
|
+
return { tokens: a.tokens, types: histogram(a.types), descriptions: { present: a.present, missing: a.missing } };
|
|
2087
|
+
}
|
|
2088
|
+
function foundationDtcg(artifact, options = {}, derivedUnits) {
|
|
1861
2089
|
const p = {
|
|
1862
2090
|
artifact,
|
|
1863
2091
|
options: { values: options.values ?? "standard", ...options.units ? { units: options.units } : {} },
|
|
2092
|
+
derivedUnits: derivedUnits ?? /* @__PURE__ */ new Map(),
|
|
1864
2093
|
tokenById: new Map(artifact.tokens.map((t) => [t.id, t])),
|
|
1865
2094
|
tokenIds: new Set(artifact.tokens.map((t) => t.id)),
|
|
1866
2095
|
collectionById: new Map(artifact.collections.map((c) => [c.id, c])),
|
|
@@ -1871,7 +2100,8 @@ function foundationDtcg(artifact, options = {}) {
|
|
|
1871
2100
|
omittedIds: /* @__PURE__ */ new Set(),
|
|
1872
2101
|
collidedIds: /* @__PURE__ */ new Set(),
|
|
1873
2102
|
report: [],
|
|
1874
|
-
reportKeys: /* @__PURE__ */ new Set()
|
|
2103
|
+
reportKeys: /* @__PURE__ */ new Set(),
|
|
2104
|
+
factsById: /* @__PURE__ */ new Map()
|
|
1875
2105
|
};
|
|
1876
2106
|
indexPaths(p);
|
|
1877
2107
|
omitInexpressibleTypes(p);
|
|
@@ -1879,23 +2109,46 @@ function foundationDtcg(artifact, options = {}) {
|
|
|
1879
2109
|
reportCollectionNameCollisions(p);
|
|
1880
2110
|
const files = {};
|
|
1881
2111
|
const plans = [];
|
|
2112
|
+
const census = {};
|
|
1882
2113
|
const taken = new Set(RESERVED_FILE_NAMES);
|
|
1883
2114
|
for (const collection of artifact.collections) {
|
|
1884
2115
|
for (const mode of collection.modes) {
|
|
1885
2116
|
const tree = {};
|
|
2117
|
+
const a = newAccumulator();
|
|
1886
2118
|
for (const token of artifact.tokens) {
|
|
1887
|
-
if (token.collection_id !== collection.id
|
|
2119
|
+
if (token.collection_id !== collection.id) continue;
|
|
2120
|
+
if (p.omittedIds.has(token.id)) {
|
|
2121
|
+
a.omitted += 1;
|
|
2122
|
+
if (p.collidedIds.has(token.id)) a.collided += 1;
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
1888
2125
|
const leaf = tokenLeaf(p, token, collection, mode.id);
|
|
1889
|
-
if (leaf)
|
|
2126
|
+
if (!leaf) continue;
|
|
2127
|
+
setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
|
|
2128
|
+
a.tokens += 1;
|
|
2129
|
+
bump(a.types, typeof leaf.$type === "string" ? leaf.$type : "unknown");
|
|
2130
|
+
const modeLabel = modeLabelOf(p, collection, mode.id);
|
|
2131
|
+
if (p.factsById.get(token.id)?.transform[modeLabel] === "alias") a.aliases += 1;
|
|
2132
|
+
else a.literals += 1;
|
|
2133
|
+
if (token.description.length > 0) a.present += 1;
|
|
2134
|
+
else a.missing += 1;
|
|
2135
|
+
for (const scope of token.scopes) bump(a.scopes, scope);
|
|
2136
|
+
if (token.code_syntax) a.codeSyntaxPresent += 1;
|
|
2137
|
+
else a.codeSyntaxMissing += 1;
|
|
2138
|
+
if (token.publication?.published) a.published += 1;
|
|
2139
|
+
if (token.publication?.hidden_from_publishing) a.hiddenFromPublishing += 1;
|
|
2140
|
+
if (!token.publication) a.unstated += 1;
|
|
1890
2141
|
}
|
|
1891
2142
|
annotateGroups(p, tree, collection);
|
|
1892
2143
|
const file = fileNameFor(collection, mode, taken);
|
|
1893
2144
|
plans.push({ collection, modeId: mode.id, file });
|
|
1894
2145
|
files[file] = sortTree(tree);
|
|
2146
|
+
census[file] = censusEntry(a);
|
|
1895
2147
|
}
|
|
1896
2148
|
}
|
|
1897
2149
|
const styles = styleFiles(p);
|
|
1898
2150
|
Object.assign(files, styles);
|
|
2151
|
+
for (const [file, tree] of Object.entries(styles)) census[file] = styleCensus(tree);
|
|
1899
2152
|
const resolver = buildResolver(p, plans, Object.keys(styles).sort(compareCodeUnits));
|
|
1900
2153
|
p.report.sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
|
|
1901
2154
|
const meta = {};
|
|
@@ -1906,7 +2159,25 @@ function foundationDtcg(artifact, options = {}) {
|
|
|
1906
2159
|
meta[p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path] = metaEntry(p, token, collection);
|
|
1907
2160
|
}
|
|
1908
2161
|
const sortedMeta = Object.fromEntries(Object.entries(meta).sort(([a], [b]) => compareCodeUnits(a, b)));
|
|
1909
|
-
|
|
2162
|
+
const codeSyntax = {};
|
|
2163
|
+
for (const [path, entry2] of Object.entries(sortedMeta)) {
|
|
2164
|
+
if (entry2.code_syntax) codeSyntax[path] = entry2.code_syntax;
|
|
2165
|
+
}
|
|
2166
|
+
const sourceFileName = artifact.spec_layer.source.file_name;
|
|
2167
|
+
const extension = {
|
|
2168
|
+
schema_version: SCHEMA_VERSION,
|
|
2169
|
+
content_hash: artifact.spec_layer.export.content_hash,
|
|
2170
|
+
config_hash: `sha256:${(0, import_js_sha2563.sha256)(canonicalJson(p.options))}`,
|
|
2171
|
+
source: {
|
|
2172
|
+
provider: "figma",
|
|
2173
|
+
...typeof sourceFileName === "string" && sourceFileName.length > 0 ? { file_name: sourceFileName } : {}
|
|
2174
|
+
},
|
|
2175
|
+
completeness: artifact.completeness,
|
|
2176
|
+
code_syntax: codeSyntax,
|
|
2177
|
+
census: Object.fromEntries(Object.keys(census).sort(compareCodeUnits).map((k) => [k, census[k]])),
|
|
2178
|
+
report: p.report
|
|
2179
|
+
};
|
|
2180
|
+
return { files, resolver, meta: sortedMeta, report: p.report, extension };
|
|
1910
2181
|
}
|
|
1911
2182
|
function omitInexpressibleTypes(p) {
|
|
1912
2183
|
for (const token of p.artifact.tokens) {
|
|
@@ -2007,17 +2278,18 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2007
2278
|
});
|
|
2008
2279
|
return null;
|
|
2009
2280
|
}
|
|
2281
|
+
const terminalType = terminalOwnType(p, value.resolved.chain);
|
|
2282
|
+
if (terminalType && !("omit" in terminalType) && terminalType.$type !== typed.$type) {
|
|
2283
|
+
reportAliasTypeMismatch(p, path, targetPath, typed.$type, terminalType.$type);
|
|
2284
|
+
const transform = literalTransform(value.resolved.value, token.scopes);
|
|
2285
|
+
if (transform !== null) recordFact(p, token.id, mode, transform);
|
|
2286
|
+
return { $type: typed.$type, $value: typed.$value, ...description };
|
|
2287
|
+
}
|
|
2288
|
+
recordFact(p, token.id, mode, "alias", typed.$value);
|
|
2010
2289
|
return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
|
|
2011
2290
|
}
|
|
2012
|
-
const
|
|
2013
|
-
|
|
2014
|
-
code: "unit_override_conflicts_with_scope",
|
|
2015
|
-
severity: "warning",
|
|
2016
|
-
path,
|
|
2017
|
-
message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
|
|
2018
|
-
details: { id: token.id, override, scopes: [...token.scopes] }
|
|
2019
|
-
});
|
|
2020
|
-
});
|
|
2291
|
+
const projected = projectedLiteral(p, token, value.value, ownerFor(p, token));
|
|
2292
|
+
const converted2 = projected.converted;
|
|
2021
2293
|
if ("omit" in converted2) {
|
|
2022
2294
|
reportOnce(p, {
|
|
2023
2295
|
code: converted2.omit,
|
|
@@ -2029,6 +2301,7 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2029
2301
|
});
|
|
2030
2302
|
return null;
|
|
2031
2303
|
}
|
|
2304
|
+
if (projected.transform !== null) recordFact(p, token.id, mode, projected.transform);
|
|
2032
2305
|
return { $type: converted2.$type, $value: converted2.$value, ...description };
|
|
2033
2306
|
}
|
|
2034
2307
|
function dtcgExportFiles(out) {
|
|
@@ -2036,12 +2309,133 @@ function dtcgExportFiles(out) {
|
|
|
2036
2309
|
`;
|
|
2037
2310
|
const files = {};
|
|
2038
2311
|
for (const name of Object.keys(out.files).sort(compareCodeUnits)) files[name] = text(out.files[name]);
|
|
2039
|
-
files["resolver.json"] = text(
|
|
2312
|
+
files["resolver.json"] = text({
|
|
2313
|
+
...out.resolver,
|
|
2314
|
+
$extensions: { "com.spec-layer": out.extension }
|
|
2315
|
+
});
|
|
2040
2316
|
files["spec-layer.meta.json"] = text(out.meta);
|
|
2041
2317
|
files["report.json"] = text(out.report);
|
|
2042
2318
|
return files;
|
|
2043
2319
|
}
|
|
2044
2320
|
|
|
2321
|
+
// ../extractor/src/v5/usageUnits.ts
|
|
2322
|
+
var LENGTH_SCOPES = ["CORNER_RADIUS", "WIDTH_HEIGHT", "GAP", "FONT_SIZE", "STROKE_FLOAT"];
|
|
2323
|
+
var LENGTH_PROPERTIES = [
|
|
2324
|
+
"border-radius",
|
|
2325
|
+
"border-top-left-radius",
|
|
2326
|
+
"border-top-right-radius",
|
|
2327
|
+
"border-bottom-left-radius",
|
|
2328
|
+
"border-bottom-right-radius",
|
|
2329
|
+
"gap",
|
|
2330
|
+
"height",
|
|
2331
|
+
"padding-x",
|
|
2332
|
+
"padding-y",
|
|
2333
|
+
"width"
|
|
2334
|
+
];
|
|
2335
|
+
var MAX_ALIAS_DEPTH = 16;
|
|
2336
|
+
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2337
|
+
function foundationOf(bundle) {
|
|
2338
|
+
const artifact = bundle.foundation?.artifact;
|
|
2339
|
+
if (!isRecord2(artifact)) return null;
|
|
2340
|
+
if (!Array.isArray(artifact.tokens) || !Array.isArray(artifact.collections)) return null;
|
|
2341
|
+
return artifact;
|
|
2342
|
+
}
|
|
2343
|
+
function precedes(a, b) {
|
|
2344
|
+
return (compareCodeUnits(a.via, b.via) || compareCodeUnits(a.source, b.source) || compareCodeUnits(a.reason, b.reason)) < 0;
|
|
2345
|
+
}
|
|
2346
|
+
function bindingsOf(artifact) {
|
|
2347
|
+
if (!isRecord2(artifact) || !isRecord2(artifact.references)) return [];
|
|
2348
|
+
const bindings = artifact.references.bindings;
|
|
2349
|
+
if (!Array.isArray(bindings)) return [];
|
|
2350
|
+
return bindings.filter((b) => isRecord2(b) && b.kind === "variable" && typeof b.source_id === "string" && typeof b.property === "string");
|
|
2351
|
+
}
|
|
2352
|
+
function aliasTargets(token) {
|
|
2353
|
+
const out = [];
|
|
2354
|
+
for (const value of Object.values(token.values)) {
|
|
2355
|
+
if (value.kind !== "alias" || value.reference.external) continue;
|
|
2356
|
+
const id = value.reference.target_id;
|
|
2357
|
+
if (id !== null && !out.includes(id)) out.push(id);
|
|
2358
|
+
}
|
|
2359
|
+
return out;
|
|
2360
|
+
}
|
|
2361
|
+
function usageUnits(bundle) {
|
|
2362
|
+
const evidence = /* @__PURE__ */ new Map();
|
|
2363
|
+
const artifact = foundationOf(bundle);
|
|
2364
|
+
if (artifact === null) return evidence;
|
|
2365
|
+
const tokenById = new Map(artifact.tokens.map((t) => [t.id, t]));
|
|
2366
|
+
const collectionById = new Map(artifact.collections.map((c) => [c.id, c]));
|
|
2367
|
+
const lengthUse = /* @__PURE__ */ new Map();
|
|
2368
|
+
const nonLengthUse = [];
|
|
2369
|
+
for (const component of bundle.components) {
|
|
2370
|
+
for (const binding of bindingsOf(component.artifact)) {
|
|
2371
|
+
if (!LENGTH_PROPERTIES.includes(binding.property)) {
|
|
2372
|
+
nonLengthUse.push(binding.source_id);
|
|
2373
|
+
continue;
|
|
2374
|
+
}
|
|
2375
|
+
const found = {
|
|
2376
|
+
unit: "px",
|
|
2377
|
+
via: "binding",
|
|
2378
|
+
source: component.name,
|
|
2379
|
+
reason: binding.property
|
|
2380
|
+
};
|
|
2381
|
+
const prior = lengthUse.get(binding.source_id);
|
|
2382
|
+
if (prior === void 0 || precedes(found, prior)) lengthUse.set(binding.source_id, found);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
const vetoedIds = /* @__PURE__ */ new Set();
|
|
2386
|
+
const walkChain = (startId, includeStart, visit) => {
|
|
2387
|
+
const seen = /* @__PURE__ */ new Set([startId]);
|
|
2388
|
+
let frontier = [startId];
|
|
2389
|
+
for (let depth = 0; depth <= MAX_ALIAS_DEPTH && frontier.length > 0; depth += 1) {
|
|
2390
|
+
const next = [];
|
|
2391
|
+
for (const id of frontier) {
|
|
2392
|
+
const token = tokenById.get(id);
|
|
2393
|
+
if (id !== startId && token !== void 0 && scopesStateUnit(token.scopes)) continue;
|
|
2394
|
+
if (id !== startId || includeStart) visit(id);
|
|
2395
|
+
if (token === void 0) continue;
|
|
2396
|
+
for (const target of aliasTargets(token)) {
|
|
2397
|
+
if (seen.has(target)) continue;
|
|
2398
|
+
seen.add(target);
|
|
2399
|
+
next.push(target);
|
|
2400
|
+
}
|
|
2401
|
+
}
|
|
2402
|
+
frontier = next;
|
|
2403
|
+
}
|
|
2404
|
+
};
|
|
2405
|
+
for (const id of nonLengthUse) walkChain(id, true, (reached) => vetoedIds.add(reached));
|
|
2406
|
+
for (const token of artifact.tokens) {
|
|
2407
|
+
if (!scopesStateNumber(token.scopes)) continue;
|
|
2408
|
+
walkChain(token.id, false, (reached) => vetoedIds.add(reached));
|
|
2409
|
+
}
|
|
2410
|
+
const isCandidate = (id) => {
|
|
2411
|
+
const token = tokenById.get(id);
|
|
2412
|
+
return token !== void 0 && token.type === "number" && !scopesStateUnit(token.scopes);
|
|
2413
|
+
};
|
|
2414
|
+
const record = (id, found) => {
|
|
2415
|
+
if (!isCandidate(id) || vetoedIds.has(id)) return;
|
|
2416
|
+
const prior = evidence.get(id);
|
|
2417
|
+
if (prior === void 0 || precedes(found, prior)) evidence.set(id, found);
|
|
2418
|
+
};
|
|
2419
|
+
const pin = (startId, found, includeStart) => walkChain(startId, includeStart, (id) => record(id, found));
|
|
2420
|
+
for (const token of artifact.tokens) {
|
|
2421
|
+
const scopes = token.scopes.filter((s) => LENGTH_SCOPES.includes(s)).sort(compareCodeUnits);
|
|
2422
|
+
if (scopes.length === 0) continue;
|
|
2423
|
+
const collection = collectionById.get(token.collection_id);
|
|
2424
|
+
if (collection === void 0) continue;
|
|
2425
|
+
pin(token.id, {
|
|
2426
|
+
unit: "px",
|
|
2427
|
+
via: "alias-scope",
|
|
2428
|
+
source: dtcgPathOf(collection.name, token.name),
|
|
2429
|
+
reason: scopes[0]
|
|
2430
|
+
}, false);
|
|
2431
|
+
}
|
|
2432
|
+
for (const token of artifact.tokens) {
|
|
2433
|
+
const found = lengthUse.get(token.id);
|
|
2434
|
+
if (found !== void 0) pin(token.id, found, true);
|
|
2435
|
+
}
|
|
2436
|
+
return evidence;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2045
2439
|
// ../extractor/src/v5/outputs/naming.ts
|
|
2046
2440
|
var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
|
|
2047
2441
|
function splitWords(segment) {
|
|
@@ -2255,10 +2649,22 @@ function cssValue(ctx, type, value, property) {
|
|
|
2255
2649
|
if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
|
|
2256
2650
|
break;
|
|
2257
2651
|
}
|
|
2258
|
-
case "number":
|
|
2259
2652
|
case "fontWeight":
|
|
2260
2653
|
if (typeof value === "number") return String(value);
|
|
2261
2654
|
break;
|
|
2655
|
+
case "number":
|
|
2656
|
+
if (typeof value === "number") {
|
|
2657
|
+
if (property !== "lineHeight") {
|
|
2658
|
+
report(ctx, {
|
|
2659
|
+
code: "unitless_number",
|
|
2660
|
+
severity: "warning",
|
|
2661
|
+
message: ctx.statesNumber ? "This token has no unit, because its Figma scopes state it is a unitless number. CSS reads it as a number, not a length, which is what those scopes ask for. Do not use it where a length is expected." : "This token has no unit, because its Figma variable states none. CSS reads it as a number, not a length. Narrow the variable's scopes in Figma and pull again.",
|
|
2662
|
+
details: { value, ...member }
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
return String(value);
|
|
2666
|
+
}
|
|
2667
|
+
break;
|
|
2262
2668
|
case "cubicBezier":
|
|
2263
2669
|
if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
|
|
2264
2670
|
return `cubic-bezier(${value.join(", ")})`;
|
|
@@ -2413,22 +2819,59 @@ function shadowDecl(ctx, leaf, name) {
|
|
|
2413
2819
|
return `${name}: ${parts.join(", ")};`;
|
|
2414
2820
|
}
|
|
2415
2821
|
var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
|
|
2416
|
-
function headerText(header, nameCase) {
|
|
2417
|
-
|
|
2418
|
-
|
|
2822
|
+
function headerText(header, nameCase, unitlessCount = 0, derivedCount = 0) {
|
|
2823
|
+
const lines = [
|
|
2824
|
+
`${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.`,
|
|
2825
|
+
" Do not edit. Change the design in Figma, republish, and run spec-layer pull."
|
|
2826
|
+
];
|
|
2827
|
+
if (unitlessCount > 0) {
|
|
2828
|
+
const reportFile = `${header.platform}-${header.format}.report.json`;
|
|
2829
|
+
if (unitlessCount === 1) {
|
|
2830
|
+
lines.push(
|
|
2831
|
+
" 1 property in this file has no unit, because its Figma variable states none.",
|
|
2832
|
+
` CSS reads it as a number, not a length. See outputs/${reportFile} under your pull's output directory, or narrow the variable's scopes in Figma.`
|
|
2833
|
+
);
|
|
2834
|
+
} else {
|
|
2835
|
+
lines.push(
|
|
2836
|
+
` ${unitlessCount} properties in this file have no unit, because their Figma variables state none.`,
|
|
2837
|
+
` CSS reads them as numbers, not lengths. See outputs/${reportFile} under your pull's output directory, or narrow the variables' scopes in Figma.`
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
}
|
|
2841
|
+
if (derivedCount > 0) {
|
|
2842
|
+
lines.push(
|
|
2843
|
+
derivedCount === 1 ? " 1 property in this file has a unit its own Figma variable does not state, taken from how the library uses the token." : ` ${derivedCount} properties in this file have a unit their own Figma variables do not state, taken from how the library uses those tokens.`,
|
|
2844
|
+
` See tokens/report.json under your pull's output directory for what pinned ${derivedCount === 1 ? "it" : "each one"}.`
|
|
2845
|
+
);
|
|
2846
|
+
}
|
|
2847
|
+
return `${lines.join("\n")} */`;
|
|
2419
2848
|
}
|
|
2420
|
-
function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
2849
|
+
function emitPass(sources, leavesByFile, names, alive, root, template, modes, facts) {
|
|
2421
2850
|
const entries = [];
|
|
2422
2851
|
const declared = /* @__PURE__ */ new Set();
|
|
2423
2852
|
const firstFile = /* @__PURE__ */ new Map();
|
|
2424
2853
|
const blocks = /* @__PURE__ */ new Map();
|
|
2854
|
+
const unitlessByFile = /* @__PURE__ */ new Map();
|
|
2855
|
+
const derivedByFile = /* @__PURE__ */ new Map();
|
|
2856
|
+
const note = (by, file, path) => {
|
|
2857
|
+
const set = by.get(file) ?? /* @__PURE__ */ new Set();
|
|
2858
|
+
set.add(path);
|
|
2859
|
+
by.set(file, set);
|
|
2860
|
+
};
|
|
2425
2861
|
for (const s of sources) {
|
|
2426
2862
|
const perCollection = modes?.[s.collection];
|
|
2427
2863
|
const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
|
|
2428
2864
|
const decls = [];
|
|
2429
2865
|
const declaredHere = [];
|
|
2430
2866
|
for (const leaf of leavesByFile.get(s.file) ?? []) {
|
|
2431
|
-
const ctx = {
|
|
2867
|
+
const ctx = {
|
|
2868
|
+
names,
|
|
2869
|
+
alive,
|
|
2870
|
+
report: entries,
|
|
2871
|
+
path: leaf.path,
|
|
2872
|
+
statesNumber: facts.statesNumber.has(leaf.path),
|
|
2873
|
+
...s.mode !== null ? { mode: s.mode } : {}
|
|
2874
|
+
};
|
|
2432
2875
|
if (leaf.type === "typography") {
|
|
2433
2876
|
const t = typographyDecls(ctx, leaf, names);
|
|
2434
2877
|
decls.push(...t.decls);
|
|
@@ -2447,12 +2890,17 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
|
2447
2890
|
declaredHere.push(leaf.path);
|
|
2448
2891
|
}
|
|
2449
2892
|
} else {
|
|
2893
|
+
const before = entries.length;
|
|
2450
2894
|
const v = cssValue(ctx, leaf.type, leaf.value);
|
|
2451
2895
|
if (v !== null) {
|
|
2452
2896
|
decls.push(`${name}: ${v};`);
|
|
2453
2897
|
declared.add(leaf.path);
|
|
2454
2898
|
declaredHere.push(leaf.path);
|
|
2455
2899
|
}
|
|
2900
|
+
if (entries.length > before && entries[entries.length - 1].code === "unitless_number" && !ctx.statesNumber) {
|
|
2901
|
+
note(unitlessByFile, s.file, leaf.path);
|
|
2902
|
+
}
|
|
2903
|
+
if (v !== null && facts.derived.has(leaf.path)) note(derivedByFile, s.file, leaf.path);
|
|
2456
2904
|
}
|
|
2457
2905
|
}
|
|
2458
2906
|
}
|
|
@@ -2463,7 +2911,7 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
|
2463
2911
|
if (existing) existing.decls.push(...decls);
|
|
2464
2912
|
else blocks.set(s.file, { selector, comment, decls });
|
|
2465
2913
|
}
|
|
2466
|
-
return { blocks, entries, declared, firstFile };
|
|
2914
|
+
return { blocks, entries, declared, firstFile, unitlessByFile, derivedByFile };
|
|
2467
2915
|
}
|
|
2468
2916
|
function cssOutput(exp, header, options = {}) {
|
|
2469
2917
|
const nameCase = options.case ?? CSS_DEFAULTS.case;
|
|
@@ -2497,7 +2945,11 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2497
2945
|
nameCase
|
|
2498
2946
|
});
|
|
2499
2947
|
const names = resolved2.names;
|
|
2500
|
-
const
|
|
2948
|
+
const facts = {
|
|
2949
|
+
statesNumber: new Set(Object.entries(exp.meta).filter(([, entry2]) => scopesStateNumber(entry2.scopes)).map(([path]) => path)),
|
|
2950
|
+
derived: new Set(exp.report.filter((entry2) => entry2.code === "unit_derived_from_usage").map((entry2) => entry2.path))
|
|
2951
|
+
};
|
|
2952
|
+
const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes, facts);
|
|
2501
2953
|
let alive = new Set(names.keys());
|
|
2502
2954
|
let pass = emit(alive);
|
|
2503
2955
|
for (; ; ) {
|
|
@@ -2524,11 +2976,16 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2524
2976
|
});
|
|
2525
2977
|
}
|
|
2526
2978
|
}
|
|
2527
|
-
const head = headerText(header, nameCase);
|
|
2528
2979
|
const files = {};
|
|
2529
2980
|
const imports = [];
|
|
2530
2981
|
for (const [source, block] of pass.blocks) {
|
|
2531
2982
|
const name = fileNames.get(source);
|
|
2983
|
+
const head = headerText(
|
|
2984
|
+
header,
|
|
2985
|
+
nameCase,
|
|
2986
|
+
pass.unitlessByFile.get(source)?.size ?? 0,
|
|
2987
|
+
pass.derivedByFile.get(source)?.size ?? 0
|
|
2988
|
+
);
|
|
2532
2989
|
files[name] = `${head}
|
|
2533
2990
|
|
|
2534
2991
|
${block.selector} {
|
|
@@ -2538,7 +2995,7 @@ ${block.decls.map((d) => ` ${d}`).join("\n")}
|
|
|
2538
2995
|
`;
|
|
2539
2996
|
imports.push(block.comment, `@import "./${name}";`);
|
|
2540
2997
|
}
|
|
2541
|
-
if (imports.length > 0) files[CSS_INDEX_FILE] = `${
|
|
2998
|
+
if (imports.length > 0) files[CSS_INDEX_FILE] = `${headerText(header, nameCase)}
|
|
2542
2999
|
|
|
2543
3000
|
${imports.join("\n")}
|
|
2544
3001
|
`;
|
|
@@ -2546,7 +3003,7 @@ ${imports.join("\n")}
|
|
|
2546
3003
|
}
|
|
2547
3004
|
|
|
2548
3005
|
// ../extractor/src/v5/componentContext.ts
|
|
2549
|
-
var
|
|
3006
|
+
var import_js_sha2564 = __toESM(require_sha256(), 1);
|
|
2550
3007
|
|
|
2551
3008
|
// ../extractor/src/libraryBundle.ts
|
|
2552
3009
|
var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
|
|
@@ -2557,14 +3014,14 @@ var LibraryBundleError = class extends Error {
|
|
|
2557
3014
|
this.code = code2;
|
|
2558
3015
|
}
|
|
2559
3016
|
};
|
|
2560
|
-
var
|
|
3017
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2561
3018
|
function hasContentHash(artifact) {
|
|
2562
|
-
if (!
|
|
3019
|
+
if (!isRecord3(artifact) || !isRecord3(artifact.spec_layer)) return false;
|
|
2563
3020
|
const exp = artifact.spec_layer.export;
|
|
2564
|
-
return
|
|
3021
|
+
return isRecord3(exp) && typeof exp.content_hash === "string";
|
|
2565
3022
|
}
|
|
2566
3023
|
function entry(v, where) {
|
|
2567
|
-
if (!
|
|
3024
|
+
if (!isRecord3(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
|
|
2568
3025
|
throw new LibraryBundleError("malformed", `The ${where} entry in this bundle is malformed.`);
|
|
2569
3026
|
}
|
|
2570
3027
|
return { name: v.name, ai: v.ai, artifact: v.artifact };
|
|
@@ -2581,7 +3038,7 @@ function parseLibraryBundle(input) {
|
|
|
2581
3038
|
throw new LibraryBundleError("not_json", "This is not valid JSON.");
|
|
2582
3039
|
}
|
|
2583
3040
|
}
|
|
2584
|
-
if (!
|
|
3041
|
+
if (!isRecord3(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
|
|
2585
3042
|
throw new LibraryBundleError("not_bundle", "This is not a Spec Layer library bundle.");
|
|
2586
3043
|
}
|
|
2587
3044
|
if (!supportedVersion(parsed.version)) {
|
|
@@ -2597,7 +3054,7 @@ function parseLibraryBundle(input) {
|
|
|
2597
3054
|
const foundationRaw = parsed.foundation ?? null;
|
|
2598
3055
|
let foundation = null;
|
|
2599
3056
|
if (foundationRaw !== null) {
|
|
2600
|
-
if (!
|
|
3057
|
+
if (!isRecord3(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
|
|
2601
3058
|
throw new LibraryBundleError("malformed", "The foundation entry in this bundle is malformed.");
|
|
2602
3059
|
}
|
|
2603
3060
|
foundation = { ai: foundationRaw.ai, artifact: foundationRaw.artifact };
|
|
@@ -2614,7 +3071,7 @@ function parseLibraryBundle(input) {
|
|
|
2614
3071
|
}
|
|
2615
3072
|
|
|
2616
3073
|
// ../extractor/src/libraryBundleHash.ts
|
|
2617
|
-
var
|
|
3074
|
+
var import_js_sha2565 = __toESM(require_sha256(), 1);
|
|
2618
3075
|
|
|
2619
3076
|
// src/bundle.ts
|
|
2620
3077
|
function parseBundle(raw) {
|
|
@@ -2682,8 +3139,7 @@ var CODE_SYNTAX_KEY = {
|
|
|
2682
3139
|
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2683
3140
|
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2684
3141
|
var uniq = (xs) => [...new Set(xs)];
|
|
2685
|
-
function
|
|
2686
|
-
const path = join2(cwd, "package.json");
|
|
3142
|
+
function readJsonObject(path) {
|
|
2687
3143
|
if (!existsSync2(path)) return null;
|
|
2688
3144
|
let parsed;
|
|
2689
3145
|
try {
|
|
@@ -2692,7 +3148,11 @@ function readPackageJson(cwd) {
|
|
|
2692
3148
|
return null;
|
|
2693
3149
|
}
|
|
2694
3150
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2695
|
-
|
|
3151
|
+
return parsed;
|
|
3152
|
+
}
|
|
3153
|
+
function readPackageJson(cwd) {
|
|
3154
|
+
const record = readJsonObject(join2(cwd, "package.json"));
|
|
3155
|
+
if (!record) return null;
|
|
2696
3156
|
const deps = {};
|
|
2697
3157
|
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2698
3158
|
const block = record[field];
|
|
@@ -2818,6 +3278,97 @@ function isPlatform(value) {
|
|
|
2818
3278
|
function isAgentHost(value) {
|
|
2819
3279
|
return AGENT_HOSTS.includes(value);
|
|
2820
3280
|
}
|
|
3281
|
+
function fontPackageSlug(family) {
|
|
3282
|
+
return family.toLowerCase().split(" ").join("-");
|
|
3283
|
+
}
|
|
3284
|
+
function dependencyNamesFamily(dependencyName, slug2) {
|
|
3285
|
+
const lower = dependencyName.toLowerCase();
|
|
3286
|
+
return lower === slug2 || lower.endsWith(`/${slug2}`);
|
|
3287
|
+
}
|
|
3288
|
+
function cssNamesFamily(cssText, family) {
|
|
3289
|
+
if (!cssText.includes("@font-face")) return false;
|
|
3290
|
+
return cssText.includes(`"${family}"`) || cssText.includes(`'${family}'`);
|
|
3291
|
+
}
|
|
3292
|
+
function googleFontsLinkNamesFamily(htmlText, family) {
|
|
3293
|
+
for (const quoted of htmlText.split('"')) {
|
|
3294
|
+
for (const fragment of quoted.split("'")) {
|
|
3295
|
+
const trimmed = fragment.trim();
|
|
3296
|
+
const candidate = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
|
|
3297
|
+
if (!candidate.startsWith("http")) continue;
|
|
3298
|
+
let url;
|
|
3299
|
+
try {
|
|
3300
|
+
url = new URL(candidate);
|
|
3301
|
+
} catch {
|
|
3302
|
+
continue;
|
|
3303
|
+
}
|
|
3304
|
+
if (url.hostname !== "fonts.googleapis.com") continue;
|
|
3305
|
+
for (const value of url.searchParams.getAll("family")) {
|
|
3306
|
+
if (value === family || value.startsWith(`${family}:`)) return true;
|
|
3307
|
+
}
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
return false;
|
|
3311
|
+
}
|
|
3312
|
+
function missingFontSources(families, repo) {
|
|
3313
|
+
const dependencyNames = Object.keys({ ...repo.packageJson.dependencies, ...repo.packageJson.devDependencies });
|
|
3314
|
+
return families.filter((family) => {
|
|
3315
|
+
const slug2 = fontPackageSlug(family);
|
|
3316
|
+
if (dependencyNames.some((name) => dependencyNamesFamily(name, slug2))) return false;
|
|
3317
|
+
if (cssNamesFamily(repo.cssText, family)) return false;
|
|
3318
|
+
if (googleFontsLinkNamesFamily(repo.htmlText, family)) return false;
|
|
3319
|
+
return true;
|
|
3320
|
+
});
|
|
3321
|
+
}
|
|
3322
|
+
function dependencyField(record, field) {
|
|
3323
|
+
const block = record?.[field];
|
|
3324
|
+
if (typeof block !== "object" || block === null) return {};
|
|
3325
|
+
const out = {};
|
|
3326
|
+
for (const [name, range] of Object.entries(block)) {
|
|
3327
|
+
if (typeof range === "string") out[name] = range;
|
|
3328
|
+
}
|
|
3329
|
+
return out;
|
|
3330
|
+
}
|
|
3331
|
+
function readRootCssText(cwd) {
|
|
3332
|
+
let names = [];
|
|
3333
|
+
try {
|
|
3334
|
+
names = readdirSync(cwd);
|
|
3335
|
+
} catch {
|
|
3336
|
+
names = [];
|
|
3337
|
+
}
|
|
3338
|
+
const chunks = [];
|
|
3339
|
+
for (const name of names) {
|
|
3340
|
+
if (!name.endsWith(".css")) continue;
|
|
3341
|
+
try {
|
|
3342
|
+
chunks.push(readFileSync2(join2(cwd, name), "utf8"));
|
|
3343
|
+
} catch {
|
|
3344
|
+
}
|
|
3345
|
+
}
|
|
3346
|
+
return chunks.join("\n");
|
|
3347
|
+
}
|
|
3348
|
+
function readEntryHtmlText(cwd) {
|
|
3349
|
+
const chunks = [];
|
|
3350
|
+
for (const path of [join2(cwd, "index.html"), join2(cwd, "public", "index.html")]) {
|
|
3351
|
+
try {
|
|
3352
|
+
if (existsSync2(path)) chunks.push(readFileSync2(path, "utf8"));
|
|
3353
|
+
} catch {
|
|
3354
|
+
}
|
|
3355
|
+
}
|
|
3356
|
+
return chunks.join("\n");
|
|
3357
|
+
}
|
|
3358
|
+
function readFontRepoSignals(cwd) {
|
|
3359
|
+
const record = readJsonObject(join2(cwd, "package.json"));
|
|
3360
|
+
return {
|
|
3361
|
+
packageJson: {
|
|
3362
|
+
dependencies: dependencyField(record, "dependencies"),
|
|
3363
|
+
devDependencies: dependencyField(record, "devDependencies")
|
|
3364
|
+
},
|
|
3365
|
+
cssText: readRootCssText(cwd),
|
|
3366
|
+
htmlText: readEntryHtmlText(cwd)
|
|
3367
|
+
};
|
|
3368
|
+
}
|
|
3369
|
+
function missingFontSourcesInRepo(families, cwd) {
|
|
3370
|
+
return missingFontSources(families, readFontRepoSignals(cwd));
|
|
3371
|
+
}
|
|
2821
3372
|
|
|
2822
3373
|
// src/outputs.ts
|
|
2823
3374
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
@@ -3148,7 +3699,7 @@ async function fetchBundle(opts) {
|
|
|
3148
3699
|
}
|
|
3149
3700
|
|
|
3150
3701
|
// src/files.ts
|
|
3151
|
-
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as
|
|
3702
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
|
|
3152
3703
|
import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
|
|
3153
3704
|
|
|
3154
3705
|
// src/selection.ts
|
|
@@ -3182,6 +3733,17 @@ Available: ${available || "none"}.`
|
|
|
3182
3733
|
return bundle.components.map((c) => wanted.some((name) => matchesName(name, c.name)));
|
|
3183
3734
|
}
|
|
3184
3735
|
|
|
3736
|
+
// src/version.ts
|
|
3737
|
+
import { readFileSync as readFileSync5 } from "node:fs";
|
|
3738
|
+
function cliVersion() {
|
|
3739
|
+
try {
|
|
3740
|
+
const parsed = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
|
|
3741
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
3742
|
+
} catch {
|
|
3743
|
+
return "unknown";
|
|
3744
|
+
}
|
|
3745
|
+
}
|
|
3746
|
+
|
|
3185
3747
|
// src/files.ts
|
|
3186
3748
|
function slugify(name) {
|
|
3187
3749
|
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -3192,7 +3754,7 @@ function readManifest(outDir) {
|
|
|
3192
3754
|
const path = join5(outDir, "manifest.json");
|
|
3193
3755
|
if (!existsSync5(path)) return null;
|
|
3194
3756
|
try {
|
|
3195
|
-
const parsed = JSON.parse(
|
|
3757
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
3196
3758
|
parsed.artifacts = parsed.artifacts.map((artifact) => {
|
|
3197
3759
|
const { aiPath, ...rest } = artifact;
|
|
3198
3760
|
return {
|
|
@@ -3209,7 +3771,7 @@ function readLocalBundle(outDir) {
|
|
|
3209
3771
|
const path = join5(outDir, "bundle.json");
|
|
3210
3772
|
if (!existsSync5(path)) return null;
|
|
3211
3773
|
try {
|
|
3212
|
-
return parseBundle(
|
|
3774
|
+
return parseBundle(readFileSync6(path, "utf8"));
|
|
3213
3775
|
} catch {
|
|
3214
3776
|
throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
|
|
3215
3777
|
}
|
|
@@ -3289,7 +3851,12 @@ function writeBundleFiles(opts) {
|
|
|
3289
3851
|
if (validateLevel1(artifact).some((d) => d.severity === "error")) {
|
|
3290
3852
|
throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
|
|
3291
3853
|
}
|
|
3292
|
-
|
|
3854
|
+
put("fonts.json", json(fontRequirements(artifact)));
|
|
3855
|
+
const exp = foundationDtcg(
|
|
3856
|
+
artifact,
|
|
3857
|
+
opts.dtcg ?? {},
|
|
3858
|
+
usageUnits(opts.bundle)
|
|
3859
|
+
);
|
|
3293
3860
|
for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
|
|
3294
3861
|
path = `${outDirRel}/tokens/resolver.json`;
|
|
3295
3862
|
const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
|
|
@@ -3321,6 +3888,7 @@ function writeBundleFiles(opts) {
|
|
|
3321
3888
|
bundleHash: opts.bundleHash,
|
|
3322
3889
|
pluginVersion: opts.bundle.pluginVersion,
|
|
3323
3890
|
extractorVersion: opts.bundle.extractorVersion,
|
|
3891
|
+
cliVersion: cliVersion(),
|
|
3324
3892
|
selection,
|
|
3325
3893
|
componentSpecsDir,
|
|
3326
3894
|
artifacts,
|
|
@@ -3344,7 +3912,7 @@ function writeBundleFiles(opts) {
|
|
|
3344
3912
|
}
|
|
3345
3913
|
|
|
3346
3914
|
// src/gitignore.ts
|
|
3347
|
-
import { readFileSync as
|
|
3915
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
|
|
3348
3916
|
import { spawnSync } from "node:child_process";
|
|
3349
3917
|
import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
|
|
3350
3918
|
var COMMENT = "# Spec Layer pull key, not for committing";
|
|
@@ -3381,7 +3949,7 @@ function ensureIgnored(cwd, fileName) {
|
|
|
3381
3949
|
${fileName}
|
|
3382
3950
|
`);
|
|
3383
3951
|
} else {
|
|
3384
|
-
const body =
|
|
3952
|
+
const body = readFileSync7(path, "utf8");
|
|
3385
3953
|
if (!hasEntryLine(body, fileName)) {
|
|
3386
3954
|
const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
|
|
3387
3955
|
writeFileSync5(path, `${body}${lead}${COMMENT}
|
|
@@ -3398,12 +3966,16 @@ ${fileName}
|
|
|
3398
3966
|
}
|
|
3399
3967
|
|
|
3400
3968
|
// src/skill.ts
|
|
3401
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as
|
|
3969
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3402
3970
|
import { dirname as dirname3, join as join7 } from "node:path";
|
|
3403
3971
|
|
|
3404
3972
|
// src/tools.ts
|
|
3405
3973
|
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
3406
3974
|
var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
|
|
3975
|
+
var PULL_EXITS = {
|
|
3976
|
+
"0": "success, even when tokens/report.json or an outputs/*.report.json holds an error-severity entry (pass --strict to fail on that instead)",
|
|
3977
|
+
"1": "usage error, bad key or id, a network or server failure, or --strict with an error-severity entry in tokens/report.json or an outputs/*.report.json, including on a cached (304) pull"
|
|
3978
|
+
};
|
|
3407
3979
|
var TOOLS = [
|
|
3408
3980
|
{
|
|
3409
3981
|
name: "setup",
|
|
@@ -3434,9 +4006,9 @@ var TOOLS = [
|
|
|
3434
4006
|
},
|
|
3435
4007
|
{
|
|
3436
4008
|
name: "pull",
|
|
3437
|
-
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3438
|
-
summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path.",
|
|
3439
|
-
when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks.",
|
|
4009
|
+
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]... [--strict]",
|
|
4010
|
+
summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path. Prints a severity summary to stderr, even on a cached pull, when tokens/report.json or an outputs/*.report.json holds an error or warning.",
|
|
4011
|
+
when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks. Add --strict in CI to fail the build on an error-severity report entry.",
|
|
3440
4012
|
network: true,
|
|
3441
4013
|
needsKey: true,
|
|
3442
4014
|
writes: [
|
|
@@ -3444,7 +4016,7 @@ var TOOLS = [
|
|
|
3444
4016
|
"outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
|
|
3445
4017
|
"componentSpecsDir from speclayer.json (default component-specs/), written in place"
|
|
3446
4018
|
],
|
|
3447
|
-
exits:
|
|
4019
|
+
exits: PULL_EXITS
|
|
3448
4020
|
},
|
|
3449
4021
|
{
|
|
3450
4022
|
name: "status",
|
|
@@ -3531,25 +4103,43 @@ function toolsJson(version) {
|
|
|
3531
4103
|
|
|
3532
4104
|
// src/skill.ts
|
|
3533
4105
|
var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
|
|
3534
|
-
function
|
|
3535
|
-
if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return
|
|
4106
|
+
function collectNumberTokenPaths(tree, path, legitimatelyUnitless, out) {
|
|
4107
|
+
if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return;
|
|
3536
4108
|
const record = tree;
|
|
3537
|
-
if (record.$type === "number" && "$value" in record)
|
|
3538
|
-
|
|
4109
|
+
if (record.$type === "number" && "$value" in record) {
|
|
4110
|
+
const dotted = path.join(".");
|
|
4111
|
+
if (!legitimatelyUnitless.has(dotted)) out.add(dotted);
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
3539
4114
|
for (const [key, value] of Object.entries(record)) {
|
|
3540
4115
|
if (key.startsWith("$")) continue;
|
|
3541
|
-
|
|
4116
|
+
collectNumberTokenPaths(value, [...path, key], legitimatelyUnitless, out);
|
|
3542
4117
|
}
|
|
3543
|
-
return n;
|
|
3544
4118
|
}
|
|
3545
4119
|
function readJson(path) {
|
|
3546
4120
|
if (!existsSync7(path)) return null;
|
|
3547
4121
|
try {
|
|
3548
|
-
return JSON.parse(
|
|
4122
|
+
return JSON.parse(readFileSync8(path, "utf8"));
|
|
3549
4123
|
} catch {
|
|
3550
4124
|
return null;
|
|
3551
4125
|
}
|
|
3552
4126
|
}
|
|
4127
|
+
function unitlessScopedPaths(tokensDir) {
|
|
4128
|
+
const meta = readJson(join7(tokensDir, "spec-layer.meta.json"));
|
|
4129
|
+
const out = /* @__PURE__ */ new Set();
|
|
4130
|
+
if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return out;
|
|
4131
|
+
for (const [path, entry2] of Object.entries(meta)) {
|
|
4132
|
+
if (typeof entry2 !== "object" || entry2 === null) continue;
|
|
4133
|
+
const { type, scopes } = entry2;
|
|
4134
|
+
if (type === "number" && Array.isArray(scopes) && scopesStateNumber(scopes)) out.add(path);
|
|
4135
|
+
}
|
|
4136
|
+
return out;
|
|
4137
|
+
}
|
|
4138
|
+
function isFontRequirement(v) {
|
|
4139
|
+
if (typeof v !== "object" || v === null) return false;
|
|
4140
|
+
const r = v;
|
|
4141
|
+
return typeof r.family === "string" && Array.isArray(r.weights) && r.weights.every((w) => typeof w === "number") && Array.isArray(r.used_by) && r.used_by.every((u) => typeof u === "string");
|
|
4142
|
+
}
|
|
3553
4143
|
function summarizePull(cwd, outDir, manifest) {
|
|
3554
4144
|
if (!manifest) return null;
|
|
3555
4145
|
const absOut = join7(cwd, outDir);
|
|
@@ -3566,17 +4156,37 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
3566
4156
|
} catch {
|
|
3567
4157
|
tokenFiles = [];
|
|
3568
4158
|
}
|
|
3569
|
-
|
|
4159
|
+
const legitimatelyUnitless = unitlessScopedPaths(tokensDir);
|
|
4160
|
+
const unitlessPaths = /* @__PURE__ */ new Set();
|
|
3570
4161
|
for (const file of tokenFiles) {
|
|
3571
4162
|
if (file.startsWith("styles.")) continue;
|
|
3572
|
-
|
|
4163
|
+
collectNumberTokenPaths(readJson(join7(tokensDir, file)), [], legitimatelyUnitless, unitlessPaths);
|
|
3573
4164
|
}
|
|
4165
|
+
const unitlessNumbers = unitlessPaths.size;
|
|
3574
4166
|
const reportCounts = {};
|
|
3575
4167
|
if (Array.isArray(report2)) {
|
|
3576
4168
|
for (const entry2 of report2) {
|
|
3577
4169
|
if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
|
|
3578
4170
|
}
|
|
3579
4171
|
}
|
|
4172
|
+
const fontsPath = join7(absOut, "fonts.json");
|
|
4173
|
+
let fontsStatus = "missing";
|
|
4174
|
+
let fonts = [];
|
|
4175
|
+
if (existsSync7(fontsPath)) {
|
|
4176
|
+
let parsed;
|
|
4177
|
+
try {
|
|
4178
|
+
parsed = JSON.parse(readFileSync8(fontsPath, "utf8"));
|
|
4179
|
+
} catch {
|
|
4180
|
+
parsed = void 0;
|
|
4181
|
+
}
|
|
4182
|
+
if (Array.isArray(parsed)) {
|
|
4183
|
+
fontsStatus = "ok";
|
|
4184
|
+
fonts = parsed.filter(isFontRequirement);
|
|
4185
|
+
} else {
|
|
4186
|
+
fontsStatus = "unreadable";
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
const missingFontFamilies = fontsStatus === "ok" && fonts.length > 0 ? missingFontSourcesInRepo(fonts.map((f) => f.family), cwd) : [];
|
|
3580
4190
|
foundation = {
|
|
3581
4191
|
written: foundationEntry.path !== null && resolver !== null,
|
|
3582
4192
|
sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
|
|
@@ -3587,7 +4197,10 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
3587
4197
|
})) : [],
|
|
3588
4198
|
tokenFiles,
|
|
3589
4199
|
unitlessNumbers,
|
|
3590
|
-
reportCounts
|
|
4200
|
+
reportCounts,
|
|
4201
|
+
fontsStatus,
|
|
4202
|
+
fonts,
|
|
4203
|
+
missingFontFamilies
|
|
3591
4204
|
};
|
|
3592
4205
|
}
|
|
3593
4206
|
return {
|
|
@@ -3651,12 +4264,49 @@ function stackSection(input) {
|
|
|
3651
4264
|
const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
|
|
3652
4265
|
lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
|
|
3653
4266
|
}
|
|
4267
|
+
const tokensDir = `${input.outDir}/tokens/`;
|
|
4268
|
+
if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
|
|
4269
|
+
const n = pull.foundation.unitlessNumbers;
|
|
4270
|
+
const cssReport = pull.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
|
|
4271
|
+
lines.push(
|
|
4272
|
+
`**${n} token${n === 1 ? " has" : "s have"} no unit.** ${n === 1 ? "Its own Figma variable states" : "Their own Figma variables state"} none at all, not even that ${n === 1 ? "it is" : "they are"} a unitless number the way an opacity or a font weight would. Those are already excluded from this count, because Figma states that for them. So ${n === 1 ? "it is" : "they are"} written as ${code('$type: "number"')}: a bare number, not usable as a CSS length. ${code("height: 36")} is invalid CSS and the browser drops the declaration. ${n === 1 ? "Narrow the variable's" : "Narrow each variable's"} scope in Figma to a length (${code("CORNER_RADIUS")}, ${code("GAP")}, ${code("WIDTH_HEIGHT")}, ${code("FONT_SIZE")}, or ${code("STROKE_FLOAT")}), or to ${code("OPACITY")}/${code("FONT_WEIGHT")} if it genuinely carries none, and pull again. Only add ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')} in ${code("speclayer.json")} once you already know the token is a length: the override applies to anything the glob matches that Figma has not already scoped as unitless, so a glob that is too broad can turn a genuine opacity or font weight into a fake length. Nothing is inferred from a name; ${code(`${tokensDir}spec-layer.meta.json`)} names each token's own Figma scopes` + (cssReport ? `, and ${code(`${input.outDir}/outputs/${cssReport.platform}-${cssReport.format}.report.json`)} names every one under ${code("unitless_number")}. Do not read that file's entry count as this number: it carries one entry per mode a token appears in, and it also names the tokens Figma does scope as a unitless number, which this count leaves out.` : "."),
|
|
4273
|
+
""
|
|
4274
|
+
);
|
|
4275
|
+
}
|
|
3654
4276
|
for (const platform of platforms) {
|
|
3655
4277
|
const key = CODE_SYNTAX_KEY[platform];
|
|
3656
|
-
const tokensDir = `${input.outDir}/tokens/`;
|
|
3657
4278
|
if (platform === "web") {
|
|
3658
4279
|
lines.push("### Web", "");
|
|
3659
4280
|
const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
|
|
4281
|
+
if (pull?.foundation?.written) {
|
|
4282
|
+
const { fontsStatus, fonts, missingFontFamilies } = pull.foundation;
|
|
4283
|
+
if (fontsStatus === "ok" && fonts.length > 0) {
|
|
4284
|
+
const fontList = fonts.map((f) => `${f.family} at ${f.weights.join(", ")}`).join("; ");
|
|
4285
|
+
lines.push(
|
|
4286
|
+
`**Fonts.** This library's type is ${fontList}. Load every weight listed; a missing weight renders as a synthesised bold that matches nothing in the design.`
|
|
4287
|
+
);
|
|
4288
|
+
for (const family of missingFontFamilies) {
|
|
4289
|
+
lines.push(
|
|
4290
|
+
`${family}: nothing in this repository loads it. Add a font source before building UI, or every component that uses it renders in the browser default.`
|
|
4291
|
+
);
|
|
4292
|
+
}
|
|
4293
|
+
const fallbackTarget = cssOut ? `${cssOut.path}/` : `${input.outDir}/`;
|
|
4294
|
+
lines.push(
|
|
4295
|
+
`The token holds a family name and no fallback stack. Never write ${code("font-family")} from a token without appending a generic fallback, and never keep that fallback inside ${code(fallbackTarget)}: the next pull replaces it.`,
|
|
4296
|
+
""
|
|
4297
|
+
);
|
|
4298
|
+
} else if (fontsStatus === "ok") {
|
|
4299
|
+
lines.push(
|
|
4300
|
+
`${code("fonts.json")} names no font family. Either this library's typography styles reference none, or a reference failed to resolve one; ${code("npx spec-layer show foundation")} prints the styles themselves.`,
|
|
4301
|
+
""
|
|
4302
|
+
);
|
|
4303
|
+
} else {
|
|
4304
|
+
lines.push(
|
|
4305
|
+
fontsStatus === "missing" ? `${code("fonts.json")} is missing, so the font requirement for this library is unknown here. Run ${code("npx spec-layer pull")} again: every pull that includes the Foundation writes this file, and a pull that finds it gone re-projects instead of reporting no change.` : `${code("fonts.json")} is not valid JSON, so the font requirement for this library is unknown here. Delete ${code(`${input.outDir}/fonts.json`)} and run ${code("npx spec-layer pull")} again, which rewrites it.`,
|
|
4306
|
+
""
|
|
4307
|
+
);
|
|
4308
|
+
}
|
|
4309
|
+
}
|
|
3660
4310
|
if (cssOut) {
|
|
3661
4311
|
const mapPath = `${input.outDir}/outputs/web-css.map.json`;
|
|
3662
4312
|
lines.push(
|
|
@@ -3736,13 +4386,6 @@ function stackSection(input) {
|
|
|
3736
4386
|
);
|
|
3737
4387
|
}
|
|
3738
4388
|
}
|
|
3739
|
-
if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
|
|
3740
|
-
const n = pull.foundation.unitlessNumbers;
|
|
3741
|
-
lines.push(
|
|
3742
|
-
`${n} token${n === 1 ? " is" : "s are"} exported as ${code('$type: "number"')} because the Figma scopes state no unit. If your code needs them as px or rem, declare it in ${code("speclayer.json")}: ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')}, then run ${code("spec-layer pull")}. Nothing is inferred from a name; an override that contradicts a stated scope is ignored and listed in ${code("report.json")}.`,
|
|
3743
|
-
""
|
|
3744
|
-
);
|
|
3745
|
-
}
|
|
3746
4389
|
return lines;
|
|
3747
4390
|
}
|
|
3748
4391
|
function pullSection(input) {
|
|
@@ -3921,7 +4564,7 @@ ${BLOCK_END}
|
|
|
3921
4564
|
function installSkill(cwd, host, guide) {
|
|
3922
4565
|
const target = installTarget(host);
|
|
3923
4566
|
const abs = join7(cwd, target.path);
|
|
3924
|
-
const existing = existsSync7(abs) ?
|
|
4567
|
+
const existing = existsSync7(abs) ? readFileSync8(abs, "utf8") : null;
|
|
3925
4568
|
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
3926
4569
|
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
3927
4570
|
mkdirSync3(dirname3(abs), { recursive: true });
|
|
@@ -3929,17 +4572,6 @@ function installSkill(cwd, host, guide) {
|
|
|
3929
4572
|
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
3930
4573
|
}
|
|
3931
4574
|
|
|
3932
|
-
// src/version.ts
|
|
3933
|
-
import { readFileSync as readFileSync8 } from "node:fs";
|
|
3934
|
-
function cliVersion() {
|
|
3935
|
-
try {
|
|
3936
|
-
const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
|
|
3937
|
-
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
3938
|
-
} catch {
|
|
3939
|
-
return "unknown";
|
|
3940
|
-
}
|
|
3941
|
-
}
|
|
3942
|
-
|
|
3943
4575
|
// src/commands.ts
|
|
3944
4576
|
var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
|
|
3945
4577
|
function manifestReader() {
|
|
@@ -4146,11 +4778,59 @@ git rm --cached ${ignored.line}`);
|
|
|
4146
4778
|
return 0;
|
|
4147
4779
|
}
|
|
4148
4780
|
function outputFilesOnDisk(cwd, outDir, o) {
|
|
4149
|
-
const
|
|
4150
|
-
|
|
4781
|
+
const id = outputId(o);
|
|
4782
|
+
for (const rel of [`${id}.map.json`, `${id}.report.json`]) {
|
|
4783
|
+
if (!existsSync8(join8(cwd, outDir, "outputs", rel))) return false;
|
|
4784
|
+
}
|
|
4151
4785
|
const imports = readIndexImports(cwd, o);
|
|
4152
4786
|
return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
|
|
4153
4787
|
}
|
|
4788
|
+
function foundationFilesOnDisk(cwd, outDir) {
|
|
4789
|
+
return ["fonts.json", join8("tokens", "report.json"), join8("tokens", "resolver.json")].every((rel) => existsSync8(join8(cwd, outDir, rel)));
|
|
4790
|
+
}
|
|
4791
|
+
function readJsonArray(path) {
|
|
4792
|
+
if (!existsSync8(path)) return [];
|
|
4793
|
+
try {
|
|
4794
|
+
const parsed = JSON.parse(readFileSync9(path, "utf8"));
|
|
4795
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
4796
|
+
} catch {
|
|
4797
|
+
return [];
|
|
4798
|
+
}
|
|
4799
|
+
}
|
|
4800
|
+
function readReportSeverities(path) {
|
|
4801
|
+
return readJsonArray(path).map((entry2) => entry2.severity).filter((s) => s === "error" || s === "warning" || s === "info");
|
|
4802
|
+
}
|
|
4803
|
+
function readFontFamilies(cwd, outDir) {
|
|
4804
|
+
return readJsonArray(join8(cwd, outDir, "fonts.json")).map((entry2) => entry2.family).filter((f) => typeof f === "string");
|
|
4805
|
+
}
|
|
4806
|
+
function plural(n, word) {
|
|
4807
|
+
return `${n} ${word}${n === 1 ? "" : "s"}`;
|
|
4808
|
+
}
|
|
4809
|
+
function printReportSummary(cwd, outDir, outputs, io2) {
|
|
4810
|
+
const reportPaths = [];
|
|
4811
|
+
let errors = 0;
|
|
4812
|
+
let warnings = 0;
|
|
4813
|
+
const add = (severities, path) => {
|
|
4814
|
+
if (severities.length === 0) return;
|
|
4815
|
+
errors += severities.filter((s) => s === "error").length;
|
|
4816
|
+
warnings += severities.filter((s) => s === "warning").length;
|
|
4817
|
+
reportPaths.push(path);
|
|
4818
|
+
};
|
|
4819
|
+
add(readReportSeverities(join8(cwd, outDir, "tokens", "report.json")), `${outDir}/tokens/report.json`);
|
|
4820
|
+
for (const o of outputs) {
|
|
4821
|
+
add(readReportSeverities(join8(cwd, outDir, "outputs", `${outputId(o)}.report.json`)), `${outDir}/outputs/${outputId(o)}.report.json`);
|
|
4822
|
+
}
|
|
4823
|
+
if (errors > 0 || warnings > 0) {
|
|
4824
|
+
io2.err(`${plural(errors, "error")}, ${plural(warnings, "warning")} in the token output. See ${reportPaths.join(", ")}.`);
|
|
4825
|
+
}
|
|
4826
|
+
const fontFamilies = readFontFamilies(cwd, outDir);
|
|
4827
|
+
if (fontFamilies.length > 0) {
|
|
4828
|
+
for (const family of missingFontSourcesInRepo(fontFamilies, cwd)) {
|
|
4829
|
+
io2.err(`This library needs ${family}, and nothing in this repository loads it. See fonts.json.`);
|
|
4830
|
+
}
|
|
4831
|
+
}
|
|
4832
|
+
return errors;
|
|
4833
|
+
}
|
|
4154
4834
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
4155
4835
|
const manifestAt = manifestReader();
|
|
4156
4836
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
@@ -4170,10 +4850,10 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4170
4850
|
const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
|
|
4171
4851
|
const willWriteFoundation = selection.foundation && foundationOnDisk;
|
|
4172
4852
|
const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
|
|
4173
|
-
const etag = manifest && sameOutput(
|
|
4853
|
+
const etag = manifest && manifest.cliVersion === cliVersion() && sameOutput(
|
|
4174
4854
|
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
|
|
4175
4855
|
{ selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
|
|
4176
|
-
) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
|
|
4856
|
+
) && briefsOnDisk && (!willWriteFoundation || foundationFilesOnDisk(cwd, opts.outDir)) && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
|
|
4177
4857
|
const result = await fetchBundle({
|
|
4178
4858
|
api: opts.api,
|
|
4179
4859
|
libraryId: opts.libraryId,
|
|
@@ -4187,6 +4867,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4187
4867
|
}
|
|
4188
4868
|
if (result.kind === "not_modified") {
|
|
4189
4869
|
io2.out(`Already up to date (published ${manifest?.publishedAt ?? "unknown"}).`);
|
|
4870
|
+
const cachedErrors = printReportSummary(cwd, opts.outDir, outputs, io2);
|
|
4871
|
+
if (flags.strict && cachedErrors > 0) return 1;
|
|
4190
4872
|
return 0;
|
|
4191
4873
|
}
|
|
4192
4874
|
let written;
|
|
@@ -4241,6 +4923,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4241
4923
|
const missing = platformsMissingFormat(platforms);
|
|
4242
4924
|
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
4243
4925
|
}
|
|
4926
|
+
const errors = printReportSummary(cwd, opts.outDir, outputs, io2);
|
|
4927
|
+
if (flags.strict && errors > 0) return 1;
|
|
4244
4928
|
return 0;
|
|
4245
4929
|
}
|
|
4246
4930
|
async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
@@ -4420,8 +5104,9 @@ Commands:
|
|
|
4420
5104
|
store the key, then pull
|
|
4421
5105
|
init --id lib_... [--out DIR] [selection] [--platform P]...
|
|
4422
5106
|
write speclayer.json
|
|
4423
|
-
pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
|
|
4424
|
-
fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens
|
|
5107
|
+
pull [--id lib_...] [--key sl_...] [selection] [--platform P]... [--strict]
|
|
5108
|
+
fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/;
|
|
5109
|
+
--strict exits 1 when tokens/report.json or an outputs/*.report.json holds an error-severity entry, even on a cached pull (default exit stays 0)
|
|
4425
5110
|
status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
|
|
4426
5111
|
list list every artifact in the last pull
|
|
4427
5112
|
show foundation | component NAME [--canonical]
|
|
@@ -4462,6 +5147,7 @@ async function main() {
|
|
|
4462
5147
|
canonical: { type: "boolean" },
|
|
4463
5148
|
json: { type: "boolean" },
|
|
4464
5149
|
install: { type: "boolean" },
|
|
5150
|
+
strict: { type: "boolean" },
|
|
4465
5151
|
agent: { type: "string", multiple: true },
|
|
4466
5152
|
platform: { type: "string", multiple: true }
|
|
4467
5153
|
}
|