spec-layer 0.8.2 → 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 +1 -1
- package/dist/cli.js +602 -85
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
|
|
|
64
64
|
`componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
|
|
65
65
|
need 0.7.0 or later. The `census` and `config_hash` blocks inside
|
|
66
66
|
`resolver.json`, and the `transform` and `resolved` fields in
|
|
67
|
-
`spec-layer.meta.json`, need 0.8.
|
|
67
|
+
`spec-layer.meta.json`, need 0.8.2 or later; an earlier version pulls the same
|
|
68
68
|
files without those fields.
|
|
69
69
|
|
|
70
70
|
## Commands
|
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,6 +814,26 @@ 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);
|
|
819
839
|
var SCHEMA_VERSION = "5.1.0";
|
|
@@ -1325,6 +1345,25 @@ function validateLevel1(artifact) {
|
|
|
1325
1345
|
}
|
|
1326
1346
|
}
|
|
1327
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
|
+
|
|
1328
1367
|
// ../extractor/src/v5/dtcg.ts
|
|
1329
1368
|
var import_js_sha2563 = __toESM(require_sha256(), 1);
|
|
1330
1369
|
function dtcgSegments(name) {
|
|
@@ -1344,6 +1383,9 @@ function dtcgSegments(name) {
|
|
|
1344
1383
|
}
|
|
1345
1384
|
return { segments, notes };
|
|
1346
1385
|
}
|
|
1386
|
+
function dtcgPathOf(collectionName, tokenName) {
|
|
1387
|
+
return [...dtcgSegments(collectionName).segments, ...dtcgSegments(tokenName).segments].join(".");
|
|
1388
|
+
}
|
|
1347
1389
|
function trimDashes(s) {
|
|
1348
1390
|
let start = 0;
|
|
1349
1391
|
let end = s.length;
|
|
@@ -1514,25 +1556,73 @@ function unitOverrideFor(p, token, collection) {
|
|
|
1514
1556
|
}
|
|
1515
1557
|
return void 0;
|
|
1516
1558
|
}
|
|
1517
|
-
|
|
1518
|
-
|
|
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) {
|
|
1519
1601
|
const collection = p.collectionById.get(token.collection_id);
|
|
1520
1602
|
const override = collection ? unitOverrideFor(p, token, collection) : void 0;
|
|
1521
1603
|
let literal = resolved2;
|
|
1522
1604
|
let overrode = false;
|
|
1605
|
+
let derived;
|
|
1523
1606
|
if (override !== void 0 && literal.type === "number") {
|
|
1524
|
-
if (token.scopes
|
|
1607
|
+
if (scopesStateNumber(token.scopes)) owner?.overrideConflict(override);
|
|
1525
1608
|
else {
|
|
1526
1609
|
literal = { type: "dimension", number: literal.value, unit: override };
|
|
1527
1610
|
overrode = true;
|
|
1528
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
|
+
}
|
|
1529
1619
|
}
|
|
1530
1620
|
const converted2 = dtcgLiteral(literal, token.scopes, p.options.values);
|
|
1531
1621
|
if ("omit" in converted2) return { converted: converted2, transform: null };
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
};
|
|
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 };
|
|
1536
1626
|
}
|
|
1537
1627
|
function literalTransform(value, scopes) {
|
|
1538
1628
|
switch (value.type) {
|
|
@@ -1559,7 +1649,24 @@ function literalTransform(value, scopes) {
|
|
|
1559
1649
|
}
|
|
1560
1650
|
function aliasLeafType(p, token, chain, resolved2) {
|
|
1561
1651
|
const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
|
|
1562
|
-
|
|
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
|
+
});
|
|
1563
1670
|
}
|
|
1564
1671
|
function modeLabels(collection) {
|
|
1565
1672
|
const counts = /* @__PURE__ */ new Map();
|
|
@@ -1978,10 +2085,11 @@ function styleCensus(tree) {
|
|
|
1978
2085
|
walk(tree);
|
|
1979
2086
|
return { tokens: a.tokens, types: histogram(a.types), descriptions: { present: a.present, missing: a.missing } };
|
|
1980
2087
|
}
|
|
1981
|
-
function foundationDtcg(artifact, options = {}) {
|
|
2088
|
+
function foundationDtcg(artifact, options = {}, derivedUnits) {
|
|
1982
2089
|
const p = {
|
|
1983
2090
|
artifact,
|
|
1984
2091
|
options: { values: options.values ?? "standard", ...options.units ? { units: options.units } : {} },
|
|
2092
|
+
derivedUnits: derivedUnits ?? /* @__PURE__ */ new Map(),
|
|
1985
2093
|
tokenById: new Map(artifact.tokens.map((t) => [t.id, t])),
|
|
1986
2094
|
tokenIds: new Set(artifact.tokens.map((t) => t.id)),
|
|
1987
2095
|
collectionById: new Map(artifact.collections.map((c) => [c.id, c])),
|
|
@@ -2170,18 +2278,17 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2170
2278
|
});
|
|
2171
2279
|
return null;
|
|
2172
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
|
+
}
|
|
2173
2288
|
recordFact(p, token.id, mode, "alias", typed.$value);
|
|
2174
2289
|
return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
|
|
2175
2290
|
}
|
|
2176
|
-
const projected = projectedLiteral(p, token, value.value, (
|
|
2177
|
-
reportOnce(p, {
|
|
2178
|
-
code: "unit_override_conflicts_with_scope",
|
|
2179
|
-
severity: "warning",
|
|
2180
|
-
path,
|
|
2181
|
-
message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
|
|
2182
|
-
details: { id: token.id, override, scopes: [...token.scopes] }
|
|
2183
|
-
});
|
|
2184
|
-
});
|
|
2291
|
+
const projected = projectedLiteral(p, token, value.value, ownerFor(p, token));
|
|
2185
2292
|
const converted2 = projected.converted;
|
|
2186
2293
|
if ("omit" in converted2) {
|
|
2187
2294
|
reportOnce(p, {
|
|
@@ -2211,6 +2318,124 @@ function dtcgExportFiles(out) {
|
|
|
2211
2318
|
return files;
|
|
2212
2319
|
}
|
|
2213
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
|
+
|
|
2214
2439
|
// ../extractor/src/v5/outputs/naming.ts
|
|
2215
2440
|
var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
|
|
2216
2441
|
function splitWords(segment) {
|
|
@@ -2424,10 +2649,22 @@ function cssValue(ctx, type, value, property) {
|
|
|
2424
2649
|
if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
|
|
2425
2650
|
break;
|
|
2426
2651
|
}
|
|
2427
|
-
case "number":
|
|
2428
2652
|
case "fontWeight":
|
|
2429
2653
|
if (typeof value === "number") return String(value);
|
|
2430
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;
|
|
2431
2668
|
case "cubicBezier":
|
|
2432
2669
|
if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
|
|
2433
2670
|
return `cubic-bezier(${value.join(", ")})`;
|
|
@@ -2582,22 +2819,59 @@ function shadowDecl(ctx, leaf, name) {
|
|
|
2582
2819
|
return `${name}: ${parts.join(", ")};`;
|
|
2583
2820
|
}
|
|
2584
2821
|
var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
|
|
2585
|
-
function headerText(header, nameCase) {
|
|
2586
|
-
|
|
2587
|
-
|
|
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")} */`;
|
|
2588
2848
|
}
|
|
2589
|
-
function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
2849
|
+
function emitPass(sources, leavesByFile, names, alive, root, template, modes, facts) {
|
|
2590
2850
|
const entries = [];
|
|
2591
2851
|
const declared = /* @__PURE__ */ new Set();
|
|
2592
2852
|
const firstFile = /* @__PURE__ */ new Map();
|
|
2593
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
|
+
};
|
|
2594
2861
|
for (const s of sources) {
|
|
2595
2862
|
const perCollection = modes?.[s.collection];
|
|
2596
2863
|
const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
|
|
2597
2864
|
const decls = [];
|
|
2598
2865
|
const declaredHere = [];
|
|
2599
2866
|
for (const leaf of leavesByFile.get(s.file) ?? []) {
|
|
2600
|
-
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
|
+
};
|
|
2601
2875
|
if (leaf.type === "typography") {
|
|
2602
2876
|
const t = typographyDecls(ctx, leaf, names);
|
|
2603
2877
|
decls.push(...t.decls);
|
|
@@ -2616,12 +2890,17 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
|
2616
2890
|
declaredHere.push(leaf.path);
|
|
2617
2891
|
}
|
|
2618
2892
|
} else {
|
|
2893
|
+
const before = entries.length;
|
|
2619
2894
|
const v = cssValue(ctx, leaf.type, leaf.value);
|
|
2620
2895
|
if (v !== null) {
|
|
2621
2896
|
decls.push(`${name}: ${v};`);
|
|
2622
2897
|
declared.add(leaf.path);
|
|
2623
2898
|
declaredHere.push(leaf.path);
|
|
2624
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);
|
|
2625
2904
|
}
|
|
2626
2905
|
}
|
|
2627
2906
|
}
|
|
@@ -2632,7 +2911,7 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
|
2632
2911
|
if (existing) existing.decls.push(...decls);
|
|
2633
2912
|
else blocks.set(s.file, { selector, comment, decls });
|
|
2634
2913
|
}
|
|
2635
|
-
return { blocks, entries, declared, firstFile };
|
|
2914
|
+
return { blocks, entries, declared, firstFile, unitlessByFile, derivedByFile };
|
|
2636
2915
|
}
|
|
2637
2916
|
function cssOutput(exp, header, options = {}) {
|
|
2638
2917
|
const nameCase = options.case ?? CSS_DEFAULTS.case;
|
|
@@ -2666,7 +2945,11 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2666
2945
|
nameCase
|
|
2667
2946
|
});
|
|
2668
2947
|
const names = resolved2.names;
|
|
2669
|
-
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);
|
|
2670
2953
|
let alive = new Set(names.keys());
|
|
2671
2954
|
let pass = emit(alive);
|
|
2672
2955
|
for (; ; ) {
|
|
@@ -2693,11 +2976,16 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2693
2976
|
});
|
|
2694
2977
|
}
|
|
2695
2978
|
}
|
|
2696
|
-
const head = headerText(header, nameCase);
|
|
2697
2979
|
const files = {};
|
|
2698
2980
|
const imports = [];
|
|
2699
2981
|
for (const [source, block] of pass.blocks) {
|
|
2700
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
|
+
);
|
|
2701
2989
|
files[name] = `${head}
|
|
2702
2990
|
|
|
2703
2991
|
${block.selector} {
|
|
@@ -2707,7 +2995,7 @@ ${block.decls.map((d) => ` ${d}`).join("\n")}
|
|
|
2707
2995
|
`;
|
|
2708
2996
|
imports.push(block.comment, `@import "./${name}";`);
|
|
2709
2997
|
}
|
|
2710
|
-
if (imports.length > 0) files[CSS_INDEX_FILE] = `${
|
|
2998
|
+
if (imports.length > 0) files[CSS_INDEX_FILE] = `${headerText(header, nameCase)}
|
|
2711
2999
|
|
|
2712
3000
|
${imports.join("\n")}
|
|
2713
3001
|
`;
|
|
@@ -2726,14 +3014,14 @@ var LibraryBundleError = class extends Error {
|
|
|
2726
3014
|
this.code = code2;
|
|
2727
3015
|
}
|
|
2728
3016
|
};
|
|
2729
|
-
var
|
|
3017
|
+
var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2730
3018
|
function hasContentHash(artifact) {
|
|
2731
|
-
if (!
|
|
3019
|
+
if (!isRecord3(artifact) || !isRecord3(artifact.spec_layer)) return false;
|
|
2732
3020
|
const exp = artifact.spec_layer.export;
|
|
2733
|
-
return
|
|
3021
|
+
return isRecord3(exp) && typeof exp.content_hash === "string";
|
|
2734
3022
|
}
|
|
2735
3023
|
function entry(v, where) {
|
|
2736
|
-
if (!
|
|
3024
|
+
if (!isRecord3(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
|
|
2737
3025
|
throw new LibraryBundleError("malformed", `The ${where} entry in this bundle is malformed.`);
|
|
2738
3026
|
}
|
|
2739
3027
|
return { name: v.name, ai: v.ai, artifact: v.artifact };
|
|
@@ -2750,7 +3038,7 @@ function parseLibraryBundle(input) {
|
|
|
2750
3038
|
throw new LibraryBundleError("not_json", "This is not valid JSON.");
|
|
2751
3039
|
}
|
|
2752
3040
|
}
|
|
2753
|
-
if (!
|
|
3041
|
+
if (!isRecord3(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
|
|
2754
3042
|
throw new LibraryBundleError("not_bundle", "This is not a Spec Layer library bundle.");
|
|
2755
3043
|
}
|
|
2756
3044
|
if (!supportedVersion(parsed.version)) {
|
|
@@ -2766,7 +3054,7 @@ function parseLibraryBundle(input) {
|
|
|
2766
3054
|
const foundationRaw = parsed.foundation ?? null;
|
|
2767
3055
|
let foundation = null;
|
|
2768
3056
|
if (foundationRaw !== null) {
|
|
2769
|
-
if (!
|
|
3057
|
+
if (!isRecord3(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
|
|
2770
3058
|
throw new LibraryBundleError("malformed", "The foundation entry in this bundle is malformed.");
|
|
2771
3059
|
}
|
|
2772
3060
|
foundation = { ai: foundationRaw.ai, artifact: foundationRaw.artifact };
|
|
@@ -2851,8 +3139,7 @@ var CODE_SYNTAX_KEY = {
|
|
|
2851
3139
|
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2852
3140
|
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2853
3141
|
var uniq = (xs) => [...new Set(xs)];
|
|
2854
|
-
function
|
|
2855
|
-
const path = join2(cwd, "package.json");
|
|
3142
|
+
function readJsonObject(path) {
|
|
2856
3143
|
if (!existsSync2(path)) return null;
|
|
2857
3144
|
let parsed;
|
|
2858
3145
|
try {
|
|
@@ -2861,7 +3148,11 @@ function readPackageJson(cwd) {
|
|
|
2861
3148
|
return null;
|
|
2862
3149
|
}
|
|
2863
3150
|
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2864
|
-
|
|
3151
|
+
return parsed;
|
|
3152
|
+
}
|
|
3153
|
+
function readPackageJson(cwd) {
|
|
3154
|
+
const record = readJsonObject(join2(cwd, "package.json"));
|
|
3155
|
+
if (!record) return null;
|
|
2865
3156
|
const deps = {};
|
|
2866
3157
|
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2867
3158
|
const block = record[field];
|
|
@@ -2987,6 +3278,97 @@ function isPlatform(value) {
|
|
|
2987
3278
|
function isAgentHost(value) {
|
|
2988
3279
|
return AGENT_HOSTS.includes(value);
|
|
2989
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
|
+
}
|
|
2990
3372
|
|
|
2991
3373
|
// src/outputs.ts
|
|
2992
3374
|
import { readFileSync as readFileSync3 } from "node:fs";
|
|
@@ -3317,7 +3699,7 @@ async function fetchBundle(opts) {
|
|
|
3317
3699
|
}
|
|
3318
3700
|
|
|
3319
3701
|
// src/files.ts
|
|
3320
|
-
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";
|
|
3321
3703
|
import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
|
|
3322
3704
|
|
|
3323
3705
|
// src/selection.ts
|
|
@@ -3351,6 +3733,17 @@ Available: ${available || "none"}.`
|
|
|
3351
3733
|
return bundle.components.map((c) => wanted.some((name) => matchesName(name, c.name)));
|
|
3352
3734
|
}
|
|
3353
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
|
+
|
|
3354
3747
|
// src/files.ts
|
|
3355
3748
|
function slugify(name) {
|
|
3356
3749
|
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
@@ -3361,7 +3754,7 @@ function readManifest(outDir) {
|
|
|
3361
3754
|
const path = join5(outDir, "manifest.json");
|
|
3362
3755
|
if (!existsSync5(path)) return null;
|
|
3363
3756
|
try {
|
|
3364
|
-
const parsed = JSON.parse(
|
|
3757
|
+
const parsed = JSON.parse(readFileSync6(path, "utf8"));
|
|
3365
3758
|
parsed.artifacts = parsed.artifacts.map((artifact) => {
|
|
3366
3759
|
const { aiPath, ...rest } = artifact;
|
|
3367
3760
|
return {
|
|
@@ -3378,7 +3771,7 @@ function readLocalBundle(outDir) {
|
|
|
3378
3771
|
const path = join5(outDir, "bundle.json");
|
|
3379
3772
|
if (!existsSync5(path)) return null;
|
|
3380
3773
|
try {
|
|
3381
|
-
return parseBundle(
|
|
3774
|
+
return parseBundle(readFileSync6(path, "utf8"));
|
|
3382
3775
|
} catch {
|
|
3383
3776
|
throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
|
|
3384
3777
|
}
|
|
@@ -3458,7 +3851,12 @@ function writeBundleFiles(opts) {
|
|
|
3458
3851
|
if (validateLevel1(artifact).some((d) => d.severity === "error")) {
|
|
3459
3852
|
throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
|
|
3460
3853
|
}
|
|
3461
|
-
|
|
3854
|
+
put("fonts.json", json(fontRequirements(artifact)));
|
|
3855
|
+
const exp = foundationDtcg(
|
|
3856
|
+
artifact,
|
|
3857
|
+
opts.dtcg ?? {},
|
|
3858
|
+
usageUnits(opts.bundle)
|
|
3859
|
+
);
|
|
3462
3860
|
for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
|
|
3463
3861
|
path = `${outDirRel}/tokens/resolver.json`;
|
|
3464
3862
|
const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
|
|
@@ -3490,6 +3888,7 @@ function writeBundleFiles(opts) {
|
|
|
3490
3888
|
bundleHash: opts.bundleHash,
|
|
3491
3889
|
pluginVersion: opts.bundle.pluginVersion,
|
|
3492
3890
|
extractorVersion: opts.bundle.extractorVersion,
|
|
3891
|
+
cliVersion: cliVersion(),
|
|
3493
3892
|
selection,
|
|
3494
3893
|
componentSpecsDir,
|
|
3495
3894
|
artifacts,
|
|
@@ -3513,7 +3912,7 @@ function writeBundleFiles(opts) {
|
|
|
3513
3912
|
}
|
|
3514
3913
|
|
|
3515
3914
|
// src/gitignore.ts
|
|
3516
|
-
import { readFileSync as
|
|
3915
|
+
import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
|
|
3517
3916
|
import { spawnSync } from "node:child_process";
|
|
3518
3917
|
import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
|
|
3519
3918
|
var COMMENT = "# Spec Layer pull key, not for committing";
|
|
@@ -3550,7 +3949,7 @@ function ensureIgnored(cwd, fileName) {
|
|
|
3550
3949
|
${fileName}
|
|
3551
3950
|
`);
|
|
3552
3951
|
} else {
|
|
3553
|
-
const body =
|
|
3952
|
+
const body = readFileSync7(path, "utf8");
|
|
3554
3953
|
if (!hasEntryLine(body, fileName)) {
|
|
3555
3954
|
const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
|
|
3556
3955
|
writeFileSync5(path, `${body}${lead}${COMMENT}
|
|
@@ -3567,12 +3966,16 @@ ${fileName}
|
|
|
3567
3966
|
}
|
|
3568
3967
|
|
|
3569
3968
|
// src/skill.ts
|
|
3570
|
-
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";
|
|
3571
3970
|
import { dirname as dirname3, join as join7 } from "node:path";
|
|
3572
3971
|
|
|
3573
3972
|
// src/tools.ts
|
|
3574
3973
|
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
3575
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
|
+
};
|
|
3576
3979
|
var TOOLS = [
|
|
3577
3980
|
{
|
|
3578
3981
|
name: "setup",
|
|
@@ -3603,9 +4006,9 @@ var TOOLS = [
|
|
|
3603
4006
|
},
|
|
3604
4007
|
{
|
|
3605
4008
|
name: "pull",
|
|
3606
|
-
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3607
|
-
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.",
|
|
3608
|
-
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.",
|
|
3609
4012
|
network: true,
|
|
3610
4013
|
needsKey: true,
|
|
3611
4014
|
writes: [
|
|
@@ -3613,7 +4016,7 @@ var TOOLS = [
|
|
|
3613
4016
|
"outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
|
|
3614
4017
|
"componentSpecsDir from speclayer.json (default component-specs/), written in place"
|
|
3615
4018
|
],
|
|
3616
|
-
exits:
|
|
4019
|
+
exits: PULL_EXITS
|
|
3617
4020
|
},
|
|
3618
4021
|
{
|
|
3619
4022
|
name: "status",
|
|
@@ -3700,25 +4103,43 @@ function toolsJson(version) {
|
|
|
3700
4103
|
|
|
3701
4104
|
// src/skill.ts
|
|
3702
4105
|
var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
|
|
3703
|
-
function
|
|
3704
|
-
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;
|
|
3705
4108
|
const record = tree;
|
|
3706
|
-
if (record.$type === "number" && "$value" in record)
|
|
3707
|
-
|
|
4109
|
+
if (record.$type === "number" && "$value" in record) {
|
|
4110
|
+
const dotted = path.join(".");
|
|
4111
|
+
if (!legitimatelyUnitless.has(dotted)) out.add(dotted);
|
|
4112
|
+
return;
|
|
4113
|
+
}
|
|
3708
4114
|
for (const [key, value] of Object.entries(record)) {
|
|
3709
4115
|
if (key.startsWith("$")) continue;
|
|
3710
|
-
|
|
4116
|
+
collectNumberTokenPaths(value, [...path, key], legitimatelyUnitless, out);
|
|
3711
4117
|
}
|
|
3712
|
-
return n;
|
|
3713
4118
|
}
|
|
3714
4119
|
function readJson(path) {
|
|
3715
4120
|
if (!existsSync7(path)) return null;
|
|
3716
4121
|
try {
|
|
3717
|
-
return JSON.parse(
|
|
4122
|
+
return JSON.parse(readFileSync8(path, "utf8"));
|
|
3718
4123
|
} catch {
|
|
3719
4124
|
return null;
|
|
3720
4125
|
}
|
|
3721
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
|
+
}
|
|
3722
4143
|
function summarizePull(cwd, outDir, manifest) {
|
|
3723
4144
|
if (!manifest) return null;
|
|
3724
4145
|
const absOut = join7(cwd, outDir);
|
|
@@ -3735,17 +4156,37 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
3735
4156
|
} catch {
|
|
3736
4157
|
tokenFiles = [];
|
|
3737
4158
|
}
|
|
3738
|
-
|
|
4159
|
+
const legitimatelyUnitless = unitlessScopedPaths(tokensDir);
|
|
4160
|
+
const unitlessPaths = /* @__PURE__ */ new Set();
|
|
3739
4161
|
for (const file of tokenFiles) {
|
|
3740
4162
|
if (file.startsWith("styles.")) continue;
|
|
3741
|
-
|
|
4163
|
+
collectNumberTokenPaths(readJson(join7(tokensDir, file)), [], legitimatelyUnitless, unitlessPaths);
|
|
3742
4164
|
}
|
|
4165
|
+
const unitlessNumbers = unitlessPaths.size;
|
|
3743
4166
|
const reportCounts = {};
|
|
3744
4167
|
if (Array.isArray(report2)) {
|
|
3745
4168
|
for (const entry2 of report2) {
|
|
3746
4169
|
if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
|
|
3747
4170
|
}
|
|
3748
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) : [];
|
|
3749
4190
|
foundation = {
|
|
3750
4191
|
written: foundationEntry.path !== null && resolver !== null,
|
|
3751
4192
|
sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
|
|
@@ -3756,7 +4197,10 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
3756
4197
|
})) : [],
|
|
3757
4198
|
tokenFiles,
|
|
3758
4199
|
unitlessNumbers,
|
|
3759
|
-
reportCounts
|
|
4200
|
+
reportCounts,
|
|
4201
|
+
fontsStatus,
|
|
4202
|
+
fonts,
|
|
4203
|
+
missingFontFamilies
|
|
3760
4204
|
};
|
|
3761
4205
|
}
|
|
3762
4206
|
return {
|
|
@@ -3820,12 +4264,49 @@ function stackSection(input) {
|
|
|
3820
4264
|
const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
|
|
3821
4265
|
lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
|
|
3822
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
|
+
}
|
|
3823
4276
|
for (const platform of platforms) {
|
|
3824
4277
|
const key = CODE_SYNTAX_KEY[platform];
|
|
3825
|
-
const tokensDir = `${input.outDir}/tokens/`;
|
|
3826
4278
|
if (platform === "web") {
|
|
3827
4279
|
lines.push("### Web", "");
|
|
3828
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
|
+
}
|
|
3829
4310
|
if (cssOut) {
|
|
3830
4311
|
const mapPath = `${input.outDir}/outputs/web-css.map.json`;
|
|
3831
4312
|
lines.push(
|
|
@@ -3905,13 +4386,6 @@ function stackSection(input) {
|
|
|
3905
4386
|
);
|
|
3906
4387
|
}
|
|
3907
4388
|
}
|
|
3908
|
-
if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
|
|
3909
|
-
const n = pull.foundation.unitlessNumbers;
|
|
3910
|
-
lines.push(
|
|
3911
|
-
`${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")}.`,
|
|
3912
|
-
""
|
|
3913
|
-
);
|
|
3914
|
-
}
|
|
3915
4389
|
return lines;
|
|
3916
4390
|
}
|
|
3917
4391
|
function pullSection(input) {
|
|
@@ -4090,7 +4564,7 @@ ${BLOCK_END}
|
|
|
4090
4564
|
function installSkill(cwd, host, guide) {
|
|
4091
4565
|
const target = installTarget(host);
|
|
4092
4566
|
const abs = join7(cwd, target.path);
|
|
4093
|
-
const existing = existsSync7(abs) ?
|
|
4567
|
+
const existing = existsSync7(abs) ? readFileSync8(abs, "utf8") : null;
|
|
4094
4568
|
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
4095
4569
|
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
4096
4570
|
mkdirSync3(dirname3(abs), { recursive: true });
|
|
@@ -4098,17 +4572,6 @@ function installSkill(cwd, host, guide) {
|
|
|
4098
4572
|
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
4099
4573
|
}
|
|
4100
4574
|
|
|
4101
|
-
// src/version.ts
|
|
4102
|
-
import { readFileSync as readFileSync8 } from "node:fs";
|
|
4103
|
-
function cliVersion() {
|
|
4104
|
-
try {
|
|
4105
|
-
const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
|
|
4106
|
-
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
4107
|
-
} catch {
|
|
4108
|
-
return "unknown";
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
|
|
4112
4575
|
// src/commands.ts
|
|
4113
4576
|
var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
|
|
4114
4577
|
function manifestReader() {
|
|
@@ -4315,11 +4778,59 @@ git rm --cached ${ignored.line}`);
|
|
|
4315
4778
|
return 0;
|
|
4316
4779
|
}
|
|
4317
4780
|
function outputFilesOnDisk(cwd, outDir, o) {
|
|
4318
|
-
const
|
|
4319
|
-
|
|
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
|
+
}
|
|
4320
4785
|
const imports = readIndexImports(cwd, o);
|
|
4321
4786
|
return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
|
|
4322
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
|
+
}
|
|
4323
4834
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
4324
4835
|
const manifestAt = manifestReader();
|
|
4325
4836
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
@@ -4339,10 +4850,10 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4339
4850
|
const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
|
|
4340
4851
|
const willWriteFoundation = selection.foundation && foundationOnDisk;
|
|
4341
4852
|
const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
|
|
4342
|
-
const etag = manifest && sameOutput(
|
|
4853
|
+
const etag = manifest && manifest.cliVersion === cliVersion() && sameOutput(
|
|
4343
4854
|
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
|
|
4344
4855
|
{ selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
|
|
4345
|
-
) && 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;
|
|
4346
4857
|
const result = await fetchBundle({
|
|
4347
4858
|
api: opts.api,
|
|
4348
4859
|
libraryId: opts.libraryId,
|
|
@@ -4356,6 +4867,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4356
4867
|
}
|
|
4357
4868
|
if (result.kind === "not_modified") {
|
|
4358
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;
|
|
4359
4872
|
return 0;
|
|
4360
4873
|
}
|
|
4361
4874
|
let written;
|
|
@@ -4410,6 +4923,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4410
4923
|
const missing = platformsMissingFormat(platforms);
|
|
4411
4924
|
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
4412
4925
|
}
|
|
4926
|
+
const errors = printReportSummary(cwd, opts.outDir, outputs, io2);
|
|
4927
|
+
if (flags.strict && errors > 0) return 1;
|
|
4413
4928
|
return 0;
|
|
4414
4929
|
}
|
|
4415
4930
|
async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
@@ -4589,8 +5104,9 @@ Commands:
|
|
|
4589
5104
|
store the key, then pull
|
|
4590
5105
|
init --id lib_... [--out DIR] [selection] [--platform P]...
|
|
4591
5106
|
write speclayer.json
|
|
4592
|
-
pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
|
|
4593
|
-
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)
|
|
4594
5110
|
status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
|
|
4595
5111
|
list list every artifact in the last pull
|
|
4596
5112
|
show foundation | component NAME [--canonical]
|
|
@@ -4631,6 +5147,7 @@ async function main() {
|
|
|
4631
5147
|
canonical: { type: "boolean" },
|
|
4632
5148
|
json: { type: "boolean" },
|
|
4633
5149
|
install: { type: "boolean" },
|
|
5150
|
+
strict: { type: "boolean" },
|
|
4634
5151
|
agent: { type: "string", multiple: true },
|
|
4635
5152
|
platform: { type: "string", multiple: true }
|
|
4636
5153
|
}
|