spec-layer 0.4.0 → 0.6.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 +168 -17
- package/dist/cli.js +1591 -124
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -262,7 +262,7 @@ var require_sha256 = __commonJS({
|
|
|
262
262
|
}
|
|
263
263
|
notString = true;
|
|
264
264
|
}
|
|
265
|
-
var
|
|
265
|
+
var code2, index = 0, i, length = message.length, blocks2 = this.blocks;
|
|
266
266
|
while (index < length) {
|
|
267
267
|
if (this.hashed) {
|
|
268
268
|
this.hashed = false;
|
|
@@ -275,22 +275,22 @@ var require_sha256 = __commonJS({
|
|
|
275
275
|
}
|
|
276
276
|
} else {
|
|
277
277
|
for (i = this.start; index < length && i < 64; ++index) {
|
|
278
|
-
|
|
279
|
-
if (
|
|
280
|
-
blocks2[i >>> 2] |=
|
|
281
|
-
} else if (
|
|
282
|
-
blocks2[i >>> 2] |= (192 |
|
|
283
|
-
blocks2[i >>> 2] |= (128 |
|
|
284
|
-
} else if (
|
|
285
|
-
blocks2[i >>> 2] |= (224 |
|
|
286
|
-
blocks2[i >>> 2] |= (128 |
|
|
287
|
-
blocks2[i >>> 2] |= (128 |
|
|
278
|
+
code2 = message.charCodeAt(index);
|
|
279
|
+
if (code2 < 128) {
|
|
280
|
+
blocks2[i >>> 2] |= code2 << SHIFT[i++ & 3];
|
|
281
|
+
} else if (code2 < 2048) {
|
|
282
|
+
blocks2[i >>> 2] |= (192 | code2 >>> 6) << SHIFT[i++ & 3];
|
|
283
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
284
|
+
} else if (code2 < 55296 || code2 >= 57344) {
|
|
285
|
+
blocks2[i >>> 2] |= (224 | code2 >>> 12) << SHIFT[i++ & 3];
|
|
286
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
|
|
287
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
288
288
|
} else {
|
|
289
|
-
|
|
290
|
-
blocks2[i >>> 2] |= (240 |
|
|
291
|
-
blocks2[i >>> 2] |= (128 |
|
|
292
|
-
blocks2[i >>> 2] |= (128 |
|
|
293
|
-
blocks2[i >>> 2] |= (128 |
|
|
289
|
+
code2 = 65536 + ((code2 & 1023) << 10 | message.charCodeAt(++index) & 1023);
|
|
290
|
+
blocks2[i >>> 2] |= (240 | code2 >>> 18) << SHIFT[i++ & 3];
|
|
291
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 12 & 63) << SHIFT[i++ & 3];
|
|
292
|
+
blocks2[i >>> 2] |= (128 | code2 >>> 6 & 63) << SHIFT[i++ & 3];
|
|
293
|
+
blocks2[i >>> 2] |= (128 | code2 & 63) << SHIFT[i++ & 3];
|
|
294
294
|
}
|
|
295
295
|
}
|
|
296
296
|
}
|
|
@@ -472,24 +472,24 @@ var require_sha256 = __commonJS({
|
|
|
472
472
|
function HmacSha256(key, is224, sharedMemory) {
|
|
473
473
|
var i, type = typeof key;
|
|
474
474
|
if (type === "string") {
|
|
475
|
-
var bytes = [], length = key.length, index = 0,
|
|
475
|
+
var bytes = [], length = key.length, index = 0, code2;
|
|
476
476
|
for (i = 0; i < length; ++i) {
|
|
477
|
-
|
|
478
|
-
if (
|
|
479
|
-
bytes[index++] =
|
|
480
|
-
} else if (
|
|
481
|
-
bytes[index++] = 192 |
|
|
482
|
-
bytes[index++] = 128 |
|
|
483
|
-
} else if (
|
|
484
|
-
bytes[index++] = 224 |
|
|
485
|
-
bytes[index++] = 128 |
|
|
486
|
-
bytes[index++] = 128 |
|
|
477
|
+
code2 = key.charCodeAt(i);
|
|
478
|
+
if (code2 < 128) {
|
|
479
|
+
bytes[index++] = code2;
|
|
480
|
+
} else if (code2 < 2048) {
|
|
481
|
+
bytes[index++] = 192 | code2 >>> 6;
|
|
482
|
+
bytes[index++] = 128 | code2 & 63;
|
|
483
|
+
} else if (code2 < 55296 || code2 >= 57344) {
|
|
484
|
+
bytes[index++] = 224 | code2 >>> 12;
|
|
485
|
+
bytes[index++] = 128 | code2 >>> 6 & 63;
|
|
486
|
+
bytes[index++] = 128 | code2 & 63;
|
|
487
487
|
} else {
|
|
488
|
-
|
|
489
|
-
bytes[index++] = 240 |
|
|
490
|
-
bytes[index++] = 128 |
|
|
491
|
-
bytes[index++] = 128 |
|
|
492
|
-
bytes[index++] = 128 |
|
|
488
|
+
code2 = 65536 + ((code2 & 1023) << 10 | key.charCodeAt(++i) & 1023);
|
|
489
|
+
bytes[index++] = 240 | code2 >>> 18;
|
|
490
|
+
bytes[index++] = 128 | code2 >>> 12 & 63;
|
|
491
|
+
bytes[index++] = 128 | code2 >>> 6 & 63;
|
|
492
|
+
bytes[index++] = 128 | code2 & 63;
|
|
493
493
|
}
|
|
494
494
|
}
|
|
495
495
|
key = bytes;
|
|
@@ -559,7 +559,8 @@ var require_sha256 = __commonJS({
|
|
|
559
559
|
import { parseArgs } from "node:util";
|
|
560
560
|
|
|
561
561
|
// src/commands.ts
|
|
562
|
-
import {
|
|
562
|
+
import { existsSync as existsSync8 } from "node:fs";
|
|
563
|
+
import { join as join7, resolve as resolve4 } from "node:path";
|
|
563
564
|
|
|
564
565
|
// ../extractor/src/statesMatrix.ts
|
|
565
566
|
var STATE_ORDER = [
|
|
@@ -642,10 +643,10 @@ var DEFAULT_SEVERITY = {
|
|
|
642
643
|
EXPORT_SCOPED: "info"
|
|
643
644
|
};
|
|
644
645
|
var compareCodeUnits = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
645
|
-
function diagnostic(
|
|
646
|
+
function diagnostic(code2, fields) {
|
|
646
647
|
return {
|
|
647
|
-
code,
|
|
648
|
-
severity: DEFAULT_SEVERITY[
|
|
648
|
+
code: code2,
|
|
649
|
+
severity: DEFAULT_SEVERITY[code2],
|
|
649
650
|
entity_id: fields.entity_id,
|
|
650
651
|
...fields.mode_id !== void 0 ? { mode_id: fields.mode_id } : {},
|
|
651
652
|
message: fields.message,
|
|
@@ -1611,15 +1612,15 @@ function styleMember(p, property, scopes, path, name) {
|
|
|
1611
1612
|
});
|
|
1612
1613
|
return null;
|
|
1613
1614
|
}
|
|
1614
|
-
const
|
|
1615
|
-
if ("omit" in
|
|
1616
|
-
if (
|
|
1615
|
+
const converted2 = dtcgLiteral(property.resolved, scopes, p.options.values);
|
|
1616
|
+
if ("omit" in converted2) {
|
|
1617
|
+
if (converted2.omit === "unit_not_expressible") {
|
|
1617
1618
|
reportOnce(p, {
|
|
1618
1619
|
code: "unit_not_expressible",
|
|
1619
1620
|
severity: "info",
|
|
1620
1621
|
path,
|
|
1621
1622
|
message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
|
|
1622
|
-
details: { property: name, ...
|
|
1623
|
+
details: { property: name, ...converted2.details }
|
|
1623
1624
|
});
|
|
1624
1625
|
const d = property.resolved;
|
|
1625
1626
|
return { extension: { value: d.number, unit: d.unit } };
|
|
@@ -1629,11 +1630,11 @@ function styleMember(p, property, scopes, path, name) {
|
|
|
1629
1630
|
severity: "warning",
|
|
1630
1631
|
path,
|
|
1631
1632
|
message: `The ${name} property has a type DTCG cannot state and was omitted.`,
|
|
1632
|
-
details: { property: name, ...
|
|
1633
|
+
details: { property: name, ...converted2.details }
|
|
1633
1634
|
});
|
|
1634
1635
|
return null;
|
|
1635
1636
|
}
|
|
1636
|
-
return { value:
|
|
1637
|
+
return { value: converted2.$value };
|
|
1637
1638
|
}
|
|
1638
1639
|
var TYPOGRAPHY_MEMBERS = [
|
|
1639
1640
|
["font_family", "fontFamily", []],
|
|
@@ -1733,8 +1734,8 @@ function effectLeaf(p, style, path) {
|
|
|
1733
1734
|
}
|
|
1734
1735
|
const raw = effect[field];
|
|
1735
1736
|
if (raw === void 0) continue;
|
|
1736
|
-
const
|
|
1737
|
-
if (!("omit" in
|
|
1737
|
+
const converted2 = dtcgLiteral(raw, [], p.options.values);
|
|
1738
|
+
if (!("omit" in converted2)) shadow[name] = converted2.$value;
|
|
1738
1739
|
}
|
|
1739
1740
|
shadow.inset = effect.type === "inner_shadow";
|
|
1740
1741
|
shadows.push(shadow);
|
|
@@ -2000,7 +2001,7 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2000
2001
|
}
|
|
2001
2002
|
return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
|
|
2002
2003
|
}
|
|
2003
|
-
const
|
|
2004
|
+
const converted2 = projectedLiteral(p, token, value.value, (override) => {
|
|
2004
2005
|
reportOnce(p, {
|
|
2005
2006
|
code: "unit_override_conflicts_with_scope",
|
|
2006
2007
|
severity: "warning",
|
|
@@ -2009,18 +2010,18 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2009
2010
|
details: { id: token.id, override, scopes: [...token.scopes] }
|
|
2010
2011
|
});
|
|
2011
2012
|
});
|
|
2012
|
-
if ("omit" in
|
|
2013
|
+
if ("omit" in converted2) {
|
|
2013
2014
|
reportOnce(p, {
|
|
2014
|
-
code:
|
|
2015
|
+
code: converted2.omit,
|
|
2015
2016
|
severity: "warning",
|
|
2016
2017
|
path,
|
|
2017
2018
|
mode,
|
|
2018
|
-
message:
|
|
2019
|
-
details: { id: token.id, ...
|
|
2019
|
+
message: converted2.omit === "type_not_expressible" ? `DTCG has no ${String(converted2.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted2.details.unit)} value was omitted.`,
|
|
2020
|
+
details: { id: token.id, ...converted2.details }
|
|
2020
2021
|
});
|
|
2021
2022
|
return null;
|
|
2022
2023
|
}
|
|
2023
|
-
return { $type:
|
|
2024
|
+
return { $type: converted2.$type, $value: converted2.$value, ...description };
|
|
2024
2025
|
}
|
|
2025
2026
|
function dtcgExportFiles(out) {
|
|
2026
2027
|
const text = (v) => `${JSON.stringify(v, null, 2)}
|
|
@@ -2033,16 +2034,475 @@ function dtcgExportFiles(out) {
|
|
|
2033
2034
|
return files;
|
|
2034
2035
|
}
|
|
2035
2036
|
|
|
2037
|
+
// ../extractor/src/v5/outputs/naming.ts
|
|
2038
|
+
var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
|
|
2039
|
+
function splitWords(segment) {
|
|
2040
|
+
return segment.replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
|
|
2041
|
+
}
|
|
2042
|
+
function pathWords(path) {
|
|
2043
|
+
return path.split(".").flatMap((seg) => {
|
|
2044
|
+
const words = splitWords(seg);
|
|
2045
|
+
return words.length > 0 ? words : ["_"];
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
function joinWords(words, nameCase) {
|
|
2049
|
+
const lower = words.map((w) => w.toLowerCase());
|
|
2050
|
+
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
2051
|
+
switch (nameCase) {
|
|
2052
|
+
case "kebab":
|
|
2053
|
+
return lower.join("-");
|
|
2054
|
+
case "snake":
|
|
2055
|
+
return lower.join("_");
|
|
2056
|
+
case "constant":
|
|
2057
|
+
return lower.join("_").toUpperCase();
|
|
2058
|
+
case "camel":
|
|
2059
|
+
return lower.map((w, i) => i === 0 ? w : cap(w)).join("");
|
|
2060
|
+
case "pascal":
|
|
2061
|
+
return lower.map(cap).join("");
|
|
2062
|
+
default: {
|
|
2063
|
+
const exhaustive = nameCase;
|
|
2064
|
+
return exhaustive;
|
|
2065
|
+
}
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
function deriveName(path, nameCase) {
|
|
2069
|
+
return joinWords(pathWords(path), nameCase);
|
|
2070
|
+
}
|
|
2071
|
+
function sortReport(entries) {
|
|
2072
|
+
return [...entries].sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
|
|
2073
|
+
}
|
|
2074
|
+
function resolveNames(paths, meta, rules) {
|
|
2075
|
+
const report2 = [];
|
|
2076
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
2077
|
+
for (const path of [...paths].sort(compareCodeUnits)) {
|
|
2078
|
+
let name = null;
|
|
2079
|
+
let source = "derived";
|
|
2080
|
+
const declared = rules.codeSyntaxKey ? meta[path]?.code_syntax?.[rules.codeSyntaxKey] : void 0;
|
|
2081
|
+
if (declared !== void 0) {
|
|
2082
|
+
name = rules.acceptDeclared(declared);
|
|
2083
|
+
if (name !== null) {
|
|
2084
|
+
source = "code_syntax";
|
|
2085
|
+
} else {
|
|
2086
|
+
report2.push({
|
|
2087
|
+
code: "code_syntax_not_usable",
|
|
2088
|
+
severity: "info",
|
|
2089
|
+
path,
|
|
2090
|
+
message: `The declared ${rules.codeSyntaxKey} identifier "${declared}" is not a name this format can use; the name was derived instead.`,
|
|
2091
|
+
details: { declared, platform: rules.codeSyntaxKey ?? "" }
|
|
2092
|
+
});
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
if (name === null) name = rules.affix(deriveName(path, rules.nameCase));
|
|
2096
|
+
const list = candidates.get(name) ?? [];
|
|
2097
|
+
list.push({ path, source });
|
|
2098
|
+
candidates.set(name, list);
|
|
2099
|
+
}
|
|
2100
|
+
const names = /* @__PURE__ */ new Map();
|
|
2101
|
+
const map = {};
|
|
2102
|
+
for (const [name, list] of candidates) {
|
|
2103
|
+
if (list.length > 1) {
|
|
2104
|
+
const collided = list.map((c) => c.path).sort(compareCodeUnits);
|
|
2105
|
+
for (const c of list) {
|
|
2106
|
+
report2.push({
|
|
2107
|
+
code: "name_collision",
|
|
2108
|
+
severity: "error",
|
|
2109
|
+
path: c.path,
|
|
2110
|
+
message: `${list.length} tokens would share the name ${name}; all were omitted.`,
|
|
2111
|
+
details: { name, paths: collided }
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
continue;
|
|
2115
|
+
}
|
|
2116
|
+
names.set(list[0].path, name);
|
|
2117
|
+
map[list[0].path] = { name, source: list[0].source };
|
|
2118
|
+
}
|
|
2119
|
+
const sortedMap = Object.fromEntries(Object.entries(map).sort(([a], [b]) => compareCodeUnits(a, b)));
|
|
2120
|
+
return { names, map: sortedMap, report: sortReport(report2) };
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
// ../extractor/src/v5/outputs/css.ts
|
|
2124
|
+
var CSS_DEFAULTS = {
|
|
2125
|
+
case: "kebab",
|
|
2126
|
+
root: ":root",
|
|
2127
|
+
modeSelector: '[data-theme="{mode}"]'
|
|
2128
|
+
};
|
|
2129
|
+
var CSS_HEADER_PREFIX = "/* Generated by spec-layer";
|
|
2130
|
+
var EXT = "com.spec-layer";
|
|
2131
|
+
var CUSTOM_PROPERTY = /^--[A-Za-z0-9_-]+$/;
|
|
2132
|
+
var BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
2133
|
+
function acceptCssDeclared(declared) {
|
|
2134
|
+
if (CUSTOM_PROPERTY.test(declared)) return declared;
|
|
2135
|
+
if (BARE_IDENT.test(declared)) return `--${declared}`;
|
|
2136
|
+
return null;
|
|
2137
|
+
}
|
|
2138
|
+
var asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
|
|
2139
|
+
function collectLeaves(tree, prefix, out) {
|
|
2140
|
+
const node = asRecord(tree);
|
|
2141
|
+
if (!node) return;
|
|
2142
|
+
if ("$value" in node) {
|
|
2143
|
+
const ours = asRecord(asRecord(node.$extensions)?.[EXT]);
|
|
2144
|
+
out.push({
|
|
2145
|
+
path: prefix.join("."),
|
|
2146
|
+
type: typeof node.$type === "string" ? node.$type : "",
|
|
2147
|
+
value: node.$value,
|
|
2148
|
+
ext: ours
|
|
2149
|
+
});
|
|
2150
|
+
return;
|
|
2151
|
+
}
|
|
2152
|
+
for (const key of Object.keys(node)) {
|
|
2153
|
+
if (key.startsWith("$")) continue;
|
|
2154
|
+
collectLeaves(node[key], [...prefix, key], out);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
var unpointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
2158
|
+
var refFile = (src) => {
|
|
2159
|
+
const r = asRecord(src);
|
|
2160
|
+
return r && typeof r.$ref === "string" ? r.$ref : null;
|
|
2161
|
+
};
|
|
2162
|
+
function sourcesOf(resolver) {
|
|
2163
|
+
const out = [];
|
|
2164
|
+
for (const { $ref } of resolver.resolutionOrder) {
|
|
2165
|
+
const set = /^#\/sets\/(.+)$/.exec($ref);
|
|
2166
|
+
const mod = /^#\/modifiers\/(.+)$/.exec($ref);
|
|
2167
|
+
if (set) {
|
|
2168
|
+
const label = unpointer(set[1]);
|
|
2169
|
+
for (const src of resolver.sets[label]?.sources ?? []) {
|
|
2170
|
+
const file = refFile(src);
|
|
2171
|
+
if (file) out.push({ collection: label, mode: null, file, isDefault: true });
|
|
2172
|
+
}
|
|
2173
|
+
} else if (mod) {
|
|
2174
|
+
const label = unpointer(mod[1]);
|
|
2175
|
+
const m = resolver.modifiers[label];
|
|
2176
|
+
if (!m) continue;
|
|
2177
|
+
for (const [context, srcs] of Object.entries(m.contexts)) {
|
|
2178
|
+
for (const src of srcs) {
|
|
2179
|
+
const file = refFile(src);
|
|
2180
|
+
if (file) out.push({ collection: label, mode: context, file, isDefault: context === m.default });
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
}
|
|
2184
|
+
}
|
|
2185
|
+
return out;
|
|
2186
|
+
}
|
|
2187
|
+
var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
|
|
2188
|
+
var collectionSlug = (file) => file.split(".")[0];
|
|
2189
|
+
var REF = /^\{(.+)\}$/;
|
|
2190
|
+
function hexChannels(hex) {
|
|
2191
|
+
const at = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
2192
|
+
return [at(1), at(3), at(5)];
|
|
2193
|
+
}
|
|
2194
|
+
var quoteFamily = (f) => `"${f.replace(/["\\]/g, "\\$&")}"`;
|
|
2195
|
+
function report(ctx, entry2) {
|
|
2196
|
+
ctx.report.push({ ...entry2, path: ctx.path, ...ctx.mode !== void 0 ? { mode: ctx.mode } : {} });
|
|
2197
|
+
}
|
|
2198
|
+
function cssValue(ctx, type, value, property) {
|
|
2199
|
+
const member = property !== void 0 ? { property } : {};
|
|
2200
|
+
if (typeof value === "string") {
|
|
2201
|
+
const ref = REF.exec(value);
|
|
2202
|
+
if (ref) {
|
|
2203
|
+
const name = ctx.alive.has(ref[1]) ? ctx.names.get(ref[1]) : void 0;
|
|
2204
|
+
if (name !== void 0) return `var(${name})`;
|
|
2205
|
+
report(ctx, {
|
|
2206
|
+
code: "reference_target_omitted",
|
|
2207
|
+
severity: "warning",
|
|
2208
|
+
message: `References ${ref[1]}, which this output does not emit; the value was omitted.`,
|
|
2209
|
+
details: { target: ref[1], ...member }
|
|
2210
|
+
});
|
|
2211
|
+
return null;
|
|
2212
|
+
}
|
|
2213
|
+
}
|
|
2214
|
+
switch (type) {
|
|
2215
|
+
case "color": {
|
|
2216
|
+
if (typeof value === "string") return value;
|
|
2217
|
+
const c = asRecord(value);
|
|
2218
|
+
if (c && typeof c.hex === "string" && typeof c.alpha === "number") {
|
|
2219
|
+
if (c.alpha === 1) return c.hex;
|
|
2220
|
+
const [r, g, b] = hexChannels(c.hex);
|
|
2221
|
+
return `rgb(${r} ${g} ${b} / ${c.alpha})`;
|
|
2222
|
+
}
|
|
2223
|
+
break;
|
|
2224
|
+
}
|
|
2225
|
+
case "dimension":
|
|
2226
|
+
case "duration": {
|
|
2227
|
+
if (typeof value === "string") return value;
|
|
2228
|
+
const d = asRecord(value);
|
|
2229
|
+
if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
|
|
2230
|
+
break;
|
|
2231
|
+
}
|
|
2232
|
+
case "number":
|
|
2233
|
+
case "fontWeight":
|
|
2234
|
+
if (typeof value === "number") return String(value);
|
|
2235
|
+
break;
|
|
2236
|
+
case "cubicBezier":
|
|
2237
|
+
if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
|
|
2238
|
+
return `cubic-bezier(${value.join(", ")})`;
|
|
2239
|
+
}
|
|
2240
|
+
break;
|
|
2241
|
+
case "fontFamily":
|
|
2242
|
+
if (typeof value === "string") return quoteFamily(value);
|
|
2243
|
+
if (Array.isArray(value) && value.every((f) => typeof f === "string")) {
|
|
2244
|
+
return value.map(quoteFamily).join(", ");
|
|
2245
|
+
}
|
|
2246
|
+
break;
|
|
2247
|
+
default:
|
|
2248
|
+
break;
|
|
2249
|
+
}
|
|
2250
|
+
report(ctx, {
|
|
2251
|
+
code: "not_expressible",
|
|
2252
|
+
severity: "warning",
|
|
2253
|
+
message: `CSS has no form for this ${type || "untyped"} value; it was omitted.`,
|
|
2254
|
+
details: { type, value, ...member }
|
|
2255
|
+
});
|
|
2256
|
+
return null;
|
|
2257
|
+
}
|
|
2258
|
+
var TEXT_TRANSFORM = { original: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" };
|
|
2259
|
+
var TEXT_DECORATION = { none: "none", underline: "underline", strikethrough: "line-through" };
|
|
2260
|
+
var TYPOGRAPHY_MEMBERS2 = [
|
|
2261
|
+
["fontFamily", "fontFamily"],
|
|
2262
|
+
["fontSize", "dimension"],
|
|
2263
|
+
["fontWeight", "fontWeight"],
|
|
2264
|
+
["lineHeight", "number"],
|
|
2265
|
+
["letterSpacing", "dimension"]
|
|
2266
|
+
];
|
|
2267
|
+
var TEXT_MEMBERS = [
|
|
2268
|
+
["textTransform", "textCase", TEXT_TRANSFORM],
|
|
2269
|
+
["textDecoration", "textDecoration", TEXT_DECORATION]
|
|
2270
|
+
];
|
|
2271
|
+
function typographyMemberKeys(value, ext) {
|
|
2272
|
+
const keys = [];
|
|
2273
|
+
for (const [key] of TYPOGRAPHY_MEMBERS2) {
|
|
2274
|
+
if (key in value) keys.push(key);
|
|
2275
|
+
}
|
|
2276
|
+
for (const key of ["lineHeight", "letterSpacing"]) {
|
|
2277
|
+
if (key in value) continue;
|
|
2278
|
+
const d = asRecord(ext[key]);
|
|
2279
|
+
if (d && typeof d.value === "number" && typeof d.unit === "string") keys.push(key);
|
|
2280
|
+
}
|
|
2281
|
+
for (const [key, extKey] of TEXT_MEMBERS) {
|
|
2282
|
+
if (typeof ext[extKey] === "string") keys.push(key);
|
|
2283
|
+
}
|
|
2284
|
+
return keys;
|
|
2285
|
+
}
|
|
2286
|
+
function converted(ctx, property, from, to) {
|
|
2287
|
+
report(ctx, {
|
|
2288
|
+
code: "value_converted",
|
|
2289
|
+
severity: "info",
|
|
2290
|
+
message: `${property} was restated from ${String(from.value)}${String(from.unit)} as ${to}.`,
|
|
2291
|
+
details: { property, from, to }
|
|
2292
|
+
});
|
|
2293
|
+
}
|
|
2294
|
+
function extensionDimension(ctx, ext, key, name, percentTo) {
|
|
2295
|
+
const d = asRecord(ext[key]);
|
|
2296
|
+
if (!d || typeof d.value !== "number" || typeof d.unit !== "string") return null;
|
|
2297
|
+
if (d.unit === "%") {
|
|
2298
|
+
const to = percentTo(canonicalNumber(d.value / 100));
|
|
2299
|
+
converted(ctx, key, d, to);
|
|
2300
|
+
return `${name}: ${to};`;
|
|
2301
|
+
}
|
|
2302
|
+
return `${name}: ${d.value}${d.unit};`;
|
|
2303
|
+
}
|
|
2304
|
+
function typographyDecls(ctx, leaf, names) {
|
|
2305
|
+
const decls = [];
|
|
2306
|
+
const declaredPaths = [];
|
|
2307
|
+
const value = asRecord(leaf.value) ?? {};
|
|
2308
|
+
const ext = leaf.ext ?? {};
|
|
2309
|
+
const nameFor = (key) => names.get(`${leaf.path}.${key}`);
|
|
2310
|
+
for (const [key, type] of TYPOGRAPHY_MEMBERS2) {
|
|
2311
|
+
if (!(key in value)) continue;
|
|
2312
|
+
const name = nameFor(key);
|
|
2313
|
+
if (name === void 0) continue;
|
|
2314
|
+
const css = cssValue(ctx, type, value[key], key);
|
|
2315
|
+
if (css !== null) {
|
|
2316
|
+
decls.push(`${name}: ${css};`);
|
|
2317
|
+
declaredPaths.push(`${leaf.path}.${key}`);
|
|
2318
|
+
}
|
|
2319
|
+
}
|
|
2320
|
+
if (!("lineHeight" in value)) {
|
|
2321
|
+
const name = nameFor("lineHeight");
|
|
2322
|
+
if (name !== void 0) {
|
|
2323
|
+
const d = extensionDimension(ctx, ext, "lineHeight", name, (n) => String(n));
|
|
2324
|
+
if (d) {
|
|
2325
|
+
decls.push(d);
|
|
2326
|
+
declaredPaths.push(`${leaf.path}.lineHeight`);
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
}
|
|
2330
|
+
if (!("letterSpacing" in value)) {
|
|
2331
|
+
const name = nameFor("letterSpacing");
|
|
2332
|
+
if (name !== void 0) {
|
|
2333
|
+
const d = extensionDimension(ctx, ext, "letterSpacing", name, (n) => `${n}em`);
|
|
2334
|
+
if (d) {
|
|
2335
|
+
decls.push(d);
|
|
2336
|
+
declaredPaths.push(`${leaf.path}.letterSpacing`);
|
|
2337
|
+
}
|
|
2338
|
+
}
|
|
2339
|
+
}
|
|
2340
|
+
for (const [key, extKey, table] of TEXT_MEMBERS) {
|
|
2341
|
+
const raw = ext[extKey];
|
|
2342
|
+
if (typeof raw !== "string") continue;
|
|
2343
|
+
const name = nameFor(key);
|
|
2344
|
+
if (name === void 0) continue;
|
|
2345
|
+
const css = table[raw];
|
|
2346
|
+
if (css !== void 0) {
|
|
2347
|
+
decls.push(`${name}: ${css};`);
|
|
2348
|
+
declaredPaths.push(`${leaf.path}.${key}`);
|
|
2349
|
+
} else {
|
|
2350
|
+
report(ctx, {
|
|
2351
|
+
code: "not_expressible",
|
|
2352
|
+
severity: "info",
|
|
2353
|
+
message: `CSS text properties have no form for ${extKey} "${raw}"; it was omitted.`,
|
|
2354
|
+
details: { property: extKey, value: raw }
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
2358
|
+
return { decls, declaredPaths };
|
|
2359
|
+
}
|
|
2360
|
+
var SHADOW_MEMBERS = [
|
|
2361
|
+
["offsetX", "dimension"],
|
|
2362
|
+
["offsetY", "dimension"],
|
|
2363
|
+
["blur", "dimension"],
|
|
2364
|
+
["spread", "dimension"],
|
|
2365
|
+
["color", "color"]
|
|
2366
|
+
];
|
|
2367
|
+
function shadowDecl(ctx, leaf, name) {
|
|
2368
|
+
const layers = Array.isArray(leaf.value) ? leaf.value : [];
|
|
2369
|
+
const omit = (reason, message) => {
|
|
2370
|
+
report(ctx, { code: "not_expressible", severity: "warning", message, details: { reason } });
|
|
2371
|
+
return null;
|
|
2372
|
+
};
|
|
2373
|
+
if (layers.length === 0) return omit("no_visible_shadow", "The style has no visible shadow, so no box-shadow was written.");
|
|
2374
|
+
const parts = [];
|
|
2375
|
+
for (const layer of layers) {
|
|
2376
|
+
const l = asRecord(layer);
|
|
2377
|
+
if (!l) return omit("layer_not_an_object", "A shadow layer is not an object; the style was omitted.");
|
|
2378
|
+
const members = [];
|
|
2379
|
+
for (const [key, type] of SHADOW_MEMBERS) {
|
|
2380
|
+
if (!(key in l)) return omit(`missing_${key}`, `A shadow layer has no ${key}, which box-shadow needs; the style was omitted.`);
|
|
2381
|
+
const css = cssValue(ctx, type, l[key], key);
|
|
2382
|
+
if (css === null) return null;
|
|
2383
|
+
members.push(css);
|
|
2384
|
+
}
|
|
2385
|
+
parts.push(`${l.inset === true ? "inset " : ""}${members.join(" ")}`);
|
|
2386
|
+
}
|
|
2387
|
+
return `${name}: ${parts.join(", ")};`;
|
|
2388
|
+
}
|
|
2389
|
+
var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
|
|
2390
|
+
function headerText(header, nameCase) {
|
|
2391
|
+
return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
|
|
2392
|
+
Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
|
|
2393
|
+
}
|
|
2394
|
+
function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
2395
|
+
const entries = [];
|
|
2396
|
+
const declared = /* @__PURE__ */ new Set();
|
|
2397
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
2398
|
+
for (const s of sources) {
|
|
2399
|
+
const perCollection = modes?.[s.collection];
|
|
2400
|
+
const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
|
|
2401
|
+
const decls = [];
|
|
2402
|
+
for (const leaf of leavesByFile.get(s.file) ?? []) {
|
|
2403
|
+
const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
|
|
2404
|
+
if (leaf.type === "typography") {
|
|
2405
|
+
const t = typographyDecls(ctx, leaf, names);
|
|
2406
|
+
decls.push(...t.decls);
|
|
2407
|
+
for (const p of t.declaredPaths) declared.add(p);
|
|
2408
|
+
} else {
|
|
2409
|
+
const name = names.get(leaf.path);
|
|
2410
|
+
if (name === void 0) continue;
|
|
2411
|
+
if (leaf.type === "shadow") {
|
|
2412
|
+
const d = shadowDecl(ctx, leaf, name);
|
|
2413
|
+
if (d !== null) {
|
|
2414
|
+
decls.push(d);
|
|
2415
|
+
declared.add(leaf.path);
|
|
2416
|
+
}
|
|
2417
|
+
} else {
|
|
2418
|
+
const v = cssValue(ctx, leaf.type, leaf.value);
|
|
2419
|
+
if (v !== null) {
|
|
2420
|
+
decls.push(`${name}: ${v};`);
|
|
2421
|
+
declared.add(leaf.path);
|
|
2422
|
+
}
|
|
2423
|
+
}
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
if (decls.length === 0) continue;
|
|
2427
|
+
const lines = blocks.get(selector) ?? [];
|
|
2428
|
+
lines.push(` /* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`, ...decls.map((d) => ` ${d}`));
|
|
2429
|
+
blocks.set(selector, lines);
|
|
2430
|
+
}
|
|
2431
|
+
return { blocks, entries, declared };
|
|
2432
|
+
}
|
|
2433
|
+
function cssOutput(exp, header, options = {}) {
|
|
2434
|
+
const nameCase = options.case ?? CSS_DEFAULTS.case;
|
|
2435
|
+
const root = options.root ?? CSS_DEFAULTS.root;
|
|
2436
|
+
const template = options.modeSelector ?? CSS_DEFAULTS.modeSelector;
|
|
2437
|
+
const sources = sourcesOf(exp.resolver);
|
|
2438
|
+
const leavesByFile = /* @__PURE__ */ new Map();
|
|
2439
|
+
for (const s of sources) {
|
|
2440
|
+
if (!leavesByFile.has(s.file)) {
|
|
2441
|
+
const out = [];
|
|
2442
|
+
collectLeaves(exp.files[s.file] ?? {}, [], out);
|
|
2443
|
+
leavesByFile.set(s.file, out);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
const candidatePaths = /* @__PURE__ */ new Set();
|
|
2447
|
+
for (const leaves of leavesByFile.values()) {
|
|
2448
|
+
for (const leaf of leaves) {
|
|
2449
|
+
if (leaf.type === "typography") {
|
|
2450
|
+
const value = asRecord(leaf.value) ?? {};
|
|
2451
|
+
const ext = leaf.ext ?? {};
|
|
2452
|
+
for (const key of typographyMemberKeys(value, ext)) candidatePaths.add(`${leaf.path}.${key}`);
|
|
2453
|
+
} else {
|
|
2454
|
+
candidatePaths.add(leaf.path);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
const resolved2 = resolveNames([...candidatePaths], exp.meta, {
|
|
2459
|
+
codeSyntaxKey: "WEB",
|
|
2460
|
+
acceptDeclared: acceptCssDeclared,
|
|
2461
|
+
affix: (body2) => `--${body2}`,
|
|
2462
|
+
nameCase
|
|
2463
|
+
});
|
|
2464
|
+
const names = resolved2.names;
|
|
2465
|
+
const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
|
|
2466
|
+
let alive = new Set(names.keys());
|
|
2467
|
+
let pass = emit(alive);
|
|
2468
|
+
for (; ; ) {
|
|
2469
|
+
if (pass.declared.size === alive.size) break;
|
|
2470
|
+
alive = pass.declared;
|
|
2471
|
+
pass = emit(alive);
|
|
2472
|
+
}
|
|
2473
|
+
const map = {};
|
|
2474
|
+
for (const [path, entry2] of Object.entries(resolved2.map)) {
|
|
2475
|
+
if (alive.has(path)) map[path] = entry2;
|
|
2476
|
+
}
|
|
2477
|
+
const entries = [...resolved2.report, ...pass.entries];
|
|
2478
|
+
const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
|
|
2479
|
+
if (shared.length > 1) {
|
|
2480
|
+
for (const collection of shared) {
|
|
2481
|
+
entries.push({
|
|
2482
|
+
code: "mode_selector_shared",
|
|
2483
|
+
severity: "warning",
|
|
2484
|
+
path: collection,
|
|
2485
|
+
message: `${shared.length} collections with modes share the selector template ${template}; one attribute cannot carry both axes. Declare a selector per collection under outputs[].modes.`,
|
|
2486
|
+
details: { modifiers: shared, selector: template }
|
|
2487
|
+
});
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
const body = [...pass.blocks].flatMap(([selector, lines]) => [`${selector} {`, ...lines, "}", ""]);
|
|
2491
|
+
const text = `${[headerText(header, nameCase), "", ...body].join("\n").trimEnd()}
|
|
2492
|
+
`;
|
|
2493
|
+
return { text, map, report: sortReport(entries) };
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2036
2496
|
// ../extractor/src/v5/componentContext.ts
|
|
2037
2497
|
var import_js_sha2563 = __toESM(require_sha256(), 1);
|
|
2038
2498
|
|
|
2039
2499
|
// ../extractor/src/libraryBundle.ts
|
|
2040
2500
|
var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
|
|
2041
2501
|
var LibraryBundleError = class extends Error {
|
|
2042
|
-
constructor(
|
|
2502
|
+
constructor(code2, message) {
|
|
2043
2503
|
super(message);
|
|
2044
2504
|
this.name = "LibraryBundleError";
|
|
2045
|
-
this.code =
|
|
2505
|
+
this.code = code2;
|
|
2046
2506
|
}
|
|
2047
2507
|
};
|
|
2048
2508
|
var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
@@ -2101,6 +2561,9 @@ function parseLibraryBundle(input) {
|
|
|
2101
2561
|
};
|
|
2102
2562
|
}
|
|
2103
2563
|
|
|
2564
|
+
// ../extractor/src/libraryBundleHash.ts
|
|
2565
|
+
var import_js_sha2564 = __toESM(require_sha256(), 1);
|
|
2566
|
+
|
|
2104
2567
|
// src/bundle.ts
|
|
2105
2568
|
function parseBundle(raw) {
|
|
2106
2569
|
try {
|
|
@@ -2121,8 +2584,8 @@ function parseBundle(raw) {
|
|
|
2121
2584
|
}
|
|
2122
2585
|
|
|
2123
2586
|
// src/config.ts
|
|
2124
|
-
import { readFileSync as
|
|
2125
|
-
import { join as
|
|
2587
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
|
|
2588
|
+
import { join as join3 } from "node:path";
|
|
2126
2589
|
|
|
2127
2590
|
// src/credentials.ts
|
|
2128
2591
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
@@ -2155,6 +2618,268 @@ function writeCredentials(cwd, stored) {
|
|
|
2155
2618
|
return { replaced };
|
|
2156
2619
|
}
|
|
2157
2620
|
|
|
2621
|
+
// src/detect.ts
|
|
2622
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
2623
|
+
import { join as join2 } from "node:path";
|
|
2624
|
+
var CODE_SYNTAX_KEY = {
|
|
2625
|
+
web: "WEB",
|
|
2626
|
+
ios: "iOS",
|
|
2627
|
+
android: "ANDROID",
|
|
2628
|
+
flutter: null
|
|
2629
|
+
};
|
|
2630
|
+
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2631
|
+
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2632
|
+
var uniq = (xs) => [...new Set(xs)];
|
|
2633
|
+
function readPackageJson(cwd) {
|
|
2634
|
+
const path = join2(cwd, "package.json");
|
|
2635
|
+
if (!existsSync2(path)) return null;
|
|
2636
|
+
let parsed;
|
|
2637
|
+
try {
|
|
2638
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
2639
|
+
} catch {
|
|
2640
|
+
return null;
|
|
2641
|
+
}
|
|
2642
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2643
|
+
const record = parsed;
|
|
2644
|
+
const deps = {};
|
|
2645
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2646
|
+
const block = record[field];
|
|
2647
|
+
if (typeof block !== "object" || block === null) continue;
|
|
2648
|
+
for (const [name, range] of Object.entries(block)) {
|
|
2649
|
+
if (typeof range === "string") deps[name] = range;
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
return { deps };
|
|
2653
|
+
}
|
|
2654
|
+
function majorOf(range) {
|
|
2655
|
+
const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
|
|
2656
|
+
return m ? Number(m[1]) : null;
|
|
2657
|
+
}
|
|
2658
|
+
var DEP_SIGNALS = [
|
|
2659
|
+
{ dep: "react", platform: "web", framework: "react" },
|
|
2660
|
+
{ dep: "next", platform: "web", framework: "next" },
|
|
2661
|
+
{ dep: "vue", platform: "web", framework: "vue" },
|
|
2662
|
+
{ dep: "nuxt", platform: "web", framework: "nuxt" },
|
|
2663
|
+
{ dep: "svelte", platform: "web", framework: "svelte" },
|
|
2664
|
+
{ dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
|
|
2665
|
+
{ dep: "@angular/core", platform: "web", framework: "angular" },
|
|
2666
|
+
{ dep: "solid-js", platform: "web", framework: "solid" },
|
|
2667
|
+
{ dep: "lit", platform: "web", framework: "lit" },
|
|
2668
|
+
{ dep: "astro", platform: "web", framework: "astro" },
|
|
2669
|
+
{ dep: "react-native", platform: "ios", framework: "react-native" },
|
|
2670
|
+
{ dep: "expo", platform: "ios", framework: "expo" },
|
|
2671
|
+
{ dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
|
|
2672
|
+
{ dep: "styled-components", platform: "web", framework: "styled-components" },
|
|
2673
|
+
{ dep: "@emotion/react", platform: "web", framework: "emotion" },
|
|
2674
|
+
{ dep: "sass", platform: "web", framework: "sass" },
|
|
2675
|
+
{ dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
|
|
2676
|
+
{ dep: "@stitches/react", platform: "web", framework: "stitches" },
|
|
2677
|
+
{ dep: "@pandacss/dev", platform: "web", framework: "panda" },
|
|
2678
|
+
{ dep: "style-dictionary", tokenTool: "style-dictionary" },
|
|
2679
|
+
{ dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
|
|
2680
|
+
{ dep: "typescript", language: "typescript" }
|
|
2681
|
+
];
|
|
2682
|
+
var FILE_SIGNALS = [
|
|
2683
|
+
{ test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
|
|
2684
|
+
{ test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
|
|
2685
|
+
{ test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
|
|
2686
|
+
{ test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
|
|
2687
|
+
{ test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
|
|
2688
|
+
{ test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
|
|
2689
|
+
{ test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
|
|
2690
|
+
{ test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
|
|
2691
|
+
{ test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
|
|
2692
|
+
{ test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
|
|
2693
|
+
{ test: (n) => n === "go.mod", signal: "Go module", language: "go" },
|
|
2694
|
+
{ test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
|
|
2695
|
+
{ test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
|
|
2696
|
+
{ test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
|
|
2697
|
+
{ test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
|
|
2698
|
+
{ test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
|
|
2699
|
+
{ test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
|
|
2700
|
+
{ test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
|
|
2701
|
+
{ test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
|
|
2702
|
+
{ test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
|
|
2703
|
+
{ test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
|
|
2704
|
+
{ test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
|
|
2705
|
+
{ test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
|
|
2706
|
+
{ test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
|
|
2707
|
+
];
|
|
2708
|
+
function detectRepo(cwd) {
|
|
2709
|
+
const platforms = [];
|
|
2710
|
+
const languages = [];
|
|
2711
|
+
const frameworks = [];
|
|
2712
|
+
const tokenTools = [];
|
|
2713
|
+
const agents = [];
|
|
2714
|
+
const evidence = [];
|
|
2715
|
+
let styleDictionaryMajor = null;
|
|
2716
|
+
let names = [];
|
|
2717
|
+
try {
|
|
2718
|
+
names = readdirSync(cwd).sort();
|
|
2719
|
+
} catch {
|
|
2720
|
+
names = [];
|
|
2721
|
+
}
|
|
2722
|
+
for (const name of names) {
|
|
2723
|
+
for (const rule of FILE_SIGNALS) {
|
|
2724
|
+
if (!rule.test(name)) continue;
|
|
2725
|
+
evidence.push({ signal: rule.signal, file: name });
|
|
2726
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2727
|
+
if (rule.language) languages.push(rule.language);
|
|
2728
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2729
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2730
|
+
if (rule.agent) agents.push(rule.agent);
|
|
2731
|
+
}
|
|
2732
|
+
}
|
|
2733
|
+
if (existsSync2(join2(cwd, ".github", "copilot-instructions.md")) || existsSync2(join2(cwd, ".github", "instructions"))) {
|
|
2734
|
+
evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
|
|
2735
|
+
agents.push("copilot");
|
|
2736
|
+
}
|
|
2737
|
+
const pkg = readPackageJson(cwd);
|
|
2738
|
+
if (pkg) {
|
|
2739
|
+
for (const rule of DEP_SIGNALS) {
|
|
2740
|
+
const range = pkg.deps[rule.dep];
|
|
2741
|
+
if (range === void 0) continue;
|
|
2742
|
+
evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
|
|
2743
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2744
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2745
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2746
|
+
if (rule.language) languages.push(rule.language);
|
|
2747
|
+
if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
|
|
2748
|
+
}
|
|
2749
|
+
if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
|
|
2750
|
+
}
|
|
2751
|
+
const order = (p) => PLATFORMS.indexOf(p);
|
|
2752
|
+
const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
|
|
2753
|
+
return {
|
|
2754
|
+
platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
|
|
2755
|
+
languages: uniq(languages).sort(),
|
|
2756
|
+
frameworks: uniq(frameworks).sort(),
|
|
2757
|
+
tokenTools: uniq(tokenTools).sort(),
|
|
2758
|
+
agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
|
|
2759
|
+
styleDictionaryMajor,
|
|
2760
|
+
evidence
|
|
2761
|
+
};
|
|
2762
|
+
}
|
|
2763
|
+
function isPlatform(value) {
|
|
2764
|
+
return PLATFORMS.includes(value);
|
|
2765
|
+
}
|
|
2766
|
+
function isAgentHost(value) {
|
|
2767
|
+
return AGENT_HOSTS.includes(value);
|
|
2768
|
+
}
|
|
2769
|
+
|
|
2770
|
+
// src/outputs.ts
|
|
2771
|
+
import {
|
|
2772
|
+
existsSync as existsSync3,
|
|
2773
|
+
mkdirSync,
|
|
2774
|
+
readFileSync as readFileSync3,
|
|
2775
|
+
renameSync,
|
|
2776
|
+
rmSync,
|
|
2777
|
+
writeFileSync as writeFileSync2
|
|
2778
|
+
} from "node:fs";
|
|
2779
|
+
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
2780
|
+
var FORMATS = [
|
|
2781
|
+
{ platform: "web", format: "css", defaultPath: "spec-layer/tokens.css", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
|
|
2782
|
+
];
|
|
2783
|
+
var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
|
|
2784
|
+
var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
|
|
2785
|
+
function defaultOutputs(platforms) {
|
|
2786
|
+
return FORMATS.filter((f) => f.defaultPath !== null && platforms.includes(f.platform)).map((f) => ({ platform: f.platform, format: f.format, path: f.defaultPath, case: f.defaultCase }));
|
|
2787
|
+
}
|
|
2788
|
+
function withDefaults(existing, platforms) {
|
|
2789
|
+
const covered = new Set(existing.map((o) => o.platform));
|
|
2790
|
+
return [...existing, ...defaultOutputs(platforms.filter((p) => !covered.has(p)))];
|
|
2791
|
+
}
|
|
2792
|
+
var outputId = (o) => `${o.platform}-${o.format}`;
|
|
2793
|
+
function parseOutput(value, index) {
|
|
2794
|
+
const at = `speclayer.json outputs[${index}]`;
|
|
2795
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${at} must be an object.`);
|
|
2796
|
+
const r = value;
|
|
2797
|
+
if (typeof r.platform !== "string" || typeof r.format !== "string") {
|
|
2798
|
+
throw new Error(`${at} needs "platform" and "format". Known: ${knownFormats()}.`);
|
|
2799
|
+
}
|
|
2800
|
+
const spec = specOf(r.platform, r.format);
|
|
2801
|
+
if (!spec) throw new Error(`${at}: unknown platform/format "${r.platform}/${r.format}". Known: ${knownFormats()}.`);
|
|
2802
|
+
const str = (key) => {
|
|
2803
|
+
if (r[key] !== void 0 && typeof r[key] !== "string") throw new Error(`${at} "${key}" must be a string.`);
|
|
2804
|
+
return r[key];
|
|
2805
|
+
};
|
|
2806
|
+
const path = str("path") ?? spec.defaultPath;
|
|
2807
|
+
if (path === null) throw new Error(`${at} needs "path": ${spec.platform} has no default location.`);
|
|
2808
|
+
const nameCase = str("case");
|
|
2809
|
+
if (nameCase !== void 0 && !NAME_CASES.includes(nameCase)) {
|
|
2810
|
+
throw new Error(`${at} "case" takes ${NAME_CASES.join(", ")}.`);
|
|
2811
|
+
}
|
|
2812
|
+
const root = str("root");
|
|
2813
|
+
const modeSelector = str("modeSelector");
|
|
2814
|
+
let modes;
|
|
2815
|
+
if (r.modes !== void 0) {
|
|
2816
|
+
const m = r.modes;
|
|
2817
|
+
if (typeof m !== "object" || m === null || Array.isArray(m) || !Object.values(m).every((v) => typeof v === "string")) {
|
|
2818
|
+
throw new Error(`${at} "modes" must map collection names to selector strings.`);
|
|
2819
|
+
}
|
|
2820
|
+
modes = m;
|
|
2821
|
+
}
|
|
2822
|
+
return {
|
|
2823
|
+
platform: spec.platform,
|
|
2824
|
+
format: spec.format,
|
|
2825
|
+
path,
|
|
2826
|
+
case: nameCase ?? spec.defaultCase,
|
|
2827
|
+
...root !== void 0 ? { root } : {},
|
|
2828
|
+
...modeSelector !== void 0 ? { modeSelector } : {},
|
|
2829
|
+
...modes ? { modes } : {}
|
|
2830
|
+
};
|
|
2831
|
+
}
|
|
2832
|
+
function renderOutput(exp, o, header) {
|
|
2833
|
+
switch (o.format) {
|
|
2834
|
+
case "css":
|
|
2835
|
+
return cssOutput(exp, { ...header, platform: o.platform, format: o.format }, {
|
|
2836
|
+
case: o.case,
|
|
2837
|
+
...o.root !== void 0 ? { root: o.root } : {},
|
|
2838
|
+
...o.modeSelector !== void 0 ? { modeSelector: o.modeSelector } : {},
|
|
2839
|
+
...o.modes ? { modes: o.modes } : {}
|
|
2840
|
+
});
|
|
2841
|
+
default: {
|
|
2842
|
+
const exhaustive = o.format;
|
|
2843
|
+
return exhaustive;
|
|
2844
|
+
}
|
|
2845
|
+
}
|
|
2846
|
+
}
|
|
2847
|
+
var inside = (parent, child) => {
|
|
2848
|
+
const rel = relative(parent, child);
|
|
2849
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2850
|
+
};
|
|
2851
|
+
function outputPathProblem(cwd, outDir, o) {
|
|
2852
|
+
const root = resolve(cwd);
|
|
2853
|
+
const abs = resolve(cwd, o.path);
|
|
2854
|
+
if (!inside(root, abs) || abs === root) return `${o.path} is outside this directory. Choose a path inside the repository.`;
|
|
2855
|
+
if (inside(resolve(cwd, outDir), abs)) {
|
|
2856
|
+
return `${o.path} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
|
|
2857
|
+
}
|
|
2858
|
+
if (existsSync3(abs)) {
|
|
2859
|
+
const prefix = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
|
|
2860
|
+
let head;
|
|
2861
|
+
try {
|
|
2862
|
+
head = readFileSync3(abs, "utf8").slice(0, prefix.length);
|
|
2863
|
+
} catch {
|
|
2864
|
+
return `${o.path} exists and could not be read.`;
|
|
2865
|
+
}
|
|
2866
|
+
if (head !== prefix) return `${o.path} exists and was not written by spec-layer. Choose another path or remove the file.`;
|
|
2867
|
+
}
|
|
2868
|
+
return null;
|
|
2869
|
+
}
|
|
2870
|
+
function writeOutputFile(cwd, o, text) {
|
|
2871
|
+
const abs = resolve(cwd, o.path);
|
|
2872
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
2873
|
+
const partial = `${abs}.partial`;
|
|
2874
|
+
writeFileSync2(partial, text);
|
|
2875
|
+
try {
|
|
2876
|
+
renameSync(partial, abs);
|
|
2877
|
+
} catch (err) {
|
|
2878
|
+
rmSync(partial, { force: true });
|
|
2879
|
+
throw err;
|
|
2880
|
+
}
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2158
2883
|
// src/config.ts
|
|
2159
2884
|
var DEFAULT_API = "https://api.spec-layer.com";
|
|
2160
2885
|
var DEFAULT_OUT_DIR = ".speclayer";
|
|
@@ -2164,12 +2889,12 @@ function parseInclude(value) {
|
|
|
2164
2889
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
|
|
2165
2890
|
const record = value;
|
|
2166
2891
|
if (record.foundation !== void 0 && typeof record.foundation !== "boolean") throw invalidConfig();
|
|
2167
|
-
if (record.components !== void 0 && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
|
|
2892
|
+
if (record.components !== void 0 && record.components !== null && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
|
|
2168
2893
|
throw invalidConfig();
|
|
2169
2894
|
}
|
|
2170
2895
|
return {
|
|
2171
2896
|
foundation: record.foundation === void 0 ? true : record.foundation,
|
|
2172
|
-
components: record.components
|
|
2897
|
+
components: record.components ?? null
|
|
2173
2898
|
};
|
|
2174
2899
|
}
|
|
2175
2900
|
function parseDtcg(value) {
|
|
@@ -2189,12 +2914,31 @@ function parseDtcg(value) {
|
|
|
2189
2914
|
}
|
|
2190
2915
|
return out;
|
|
2191
2916
|
}
|
|
2917
|
+
function parsePlatforms(value) {
|
|
2918
|
+
if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
|
|
2919
|
+
throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
|
|
2920
|
+
}
|
|
2921
|
+
return [...new Set(value)];
|
|
2922
|
+
}
|
|
2923
|
+
function parseOutputs(value) {
|
|
2924
|
+
if (!Array.isArray(value)) throw new Error('speclayer.json "outputs" must be an array.');
|
|
2925
|
+
const outputs = value.map((v, i) => parseOutput(v, i));
|
|
2926
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2927
|
+
for (const output of outputs) {
|
|
2928
|
+
const key = `${output.platform}/${output.format}`;
|
|
2929
|
+
if (seen.has(key)) {
|
|
2930
|
+
throw new Error(`speclayer.json "outputs" lists ${output.platform}/${output.format} more than once. Keep one entry per platform and format.`);
|
|
2931
|
+
}
|
|
2932
|
+
seen.add(key);
|
|
2933
|
+
}
|
|
2934
|
+
return outputs;
|
|
2935
|
+
}
|
|
2192
2936
|
function readConfig(cwd) {
|
|
2193
|
-
const path =
|
|
2194
|
-
if (!
|
|
2937
|
+
const path = join3(cwd, CONFIG_NAME);
|
|
2938
|
+
if (!existsSync4(path)) return null;
|
|
2195
2939
|
let parsed;
|
|
2196
2940
|
try {
|
|
2197
|
-
parsed = JSON.parse(
|
|
2941
|
+
parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
2198
2942
|
} catch {
|
|
2199
2943
|
throw invalidConfig();
|
|
2200
2944
|
}
|
|
@@ -2204,7 +2948,9 @@ function readConfig(cwd) {
|
|
|
2204
2948
|
...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
|
|
2205
2949
|
...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
|
|
2206
2950
|
...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
|
|
2207
|
-
...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
|
|
2951
|
+
...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
|
|
2952
|
+
...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
|
|
2953
|
+
...record.outputs !== void 0 ? { outputs: parseOutputs(record.outputs) } : {}
|
|
2208
2954
|
};
|
|
2209
2955
|
}
|
|
2210
2956
|
function writeConfig(cwd, config) {
|
|
@@ -2212,15 +2958,17 @@ function writeConfig(cwd, config) {
|
|
|
2212
2958
|
libraryId: config.libraryId,
|
|
2213
2959
|
outDir: config.outDir,
|
|
2214
2960
|
...config.include ? { include: config.include } : {},
|
|
2215
|
-
...config.dtcg ? { dtcg: config.dtcg } : {}
|
|
2961
|
+
...config.dtcg ? { dtcg: config.dtcg } : {},
|
|
2962
|
+
...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
|
|
2963
|
+
...config.outputs ? { outputs: config.outputs } : {}
|
|
2216
2964
|
};
|
|
2217
|
-
|
|
2965
|
+
writeFileSync3(join3(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
|
|
2218
2966
|
`);
|
|
2219
2967
|
}
|
|
2220
2968
|
function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
2221
2969
|
const config = readConfig(cwd);
|
|
2222
2970
|
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
2223
|
-
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(
|
|
2971
|
+
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join3(cwd, outDir));
|
|
2224
2972
|
const supplied = flags.key || env.SPEC_LAYER_KEY || null;
|
|
2225
2973
|
let storedKey = null;
|
|
2226
2974
|
let storedKeyFor;
|
|
@@ -2239,6 +2987,8 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
|
2239
2987
|
key: supplied ?? storedKey,
|
|
2240
2988
|
...config?.include ? { include: config.include } : {},
|
|
2241
2989
|
...config?.dtcg ? { dtcg: config.dtcg } : {},
|
|
2990
|
+
...config?.platforms ? { platforms: config.platforms } : {},
|
|
2991
|
+
...config?.outputs ? { outputs: config.outputs } : {},
|
|
2242
2992
|
...storedKeyFor ? { storedKeyFor } : {}
|
|
2243
2993
|
};
|
|
2244
2994
|
}
|
|
@@ -2277,8 +3027,8 @@ async function fetchBundle(opts) {
|
|
|
2277
3027
|
}
|
|
2278
3028
|
|
|
2279
3029
|
// src/files.ts
|
|
2280
|
-
import { mkdirSync, writeFileSync as
|
|
2281
|
-
import { join as
|
|
3030
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync2, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
|
|
3031
|
+
import { join as join4, dirname as dirname2, relative as relative2, resolve as resolve2, isAbsolute as isAbsolute2 } from "node:path";
|
|
2282
3032
|
|
|
2283
3033
|
// src/selection.ts
|
|
2284
3034
|
var DEFAULT_SELECTION = { foundation: true, components: null };
|
|
@@ -2317,19 +3067,27 @@ function slugify(name) {
|
|
|
2317
3067
|
return slug2 || "component";
|
|
2318
3068
|
}
|
|
2319
3069
|
function readManifest(outDir) {
|
|
2320
|
-
const path =
|
|
2321
|
-
if (!
|
|
3070
|
+
const path = join4(outDir, "manifest.json");
|
|
3071
|
+
if (!existsSync5(path)) return null;
|
|
2322
3072
|
try {
|
|
2323
|
-
|
|
3073
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
3074
|
+
parsed.artifacts = parsed.artifacts.map((artifact) => {
|
|
3075
|
+
const { aiPath, ...rest } = artifact;
|
|
3076
|
+
return {
|
|
3077
|
+
...rest,
|
|
3078
|
+
path: rest.path ?? aiPath ?? null
|
|
3079
|
+
};
|
|
3080
|
+
});
|
|
3081
|
+
return parsed;
|
|
2324
3082
|
} catch {
|
|
2325
3083
|
return null;
|
|
2326
3084
|
}
|
|
2327
3085
|
}
|
|
2328
3086
|
function readLocalBundle(outDir) {
|
|
2329
|
-
const path =
|
|
2330
|
-
if (!
|
|
3087
|
+
const path = join4(outDir, "bundle.json");
|
|
3088
|
+
if (!existsSync5(path)) return null;
|
|
2331
3089
|
try {
|
|
2332
|
-
return parseBundle(
|
|
3090
|
+
return parseBundle(readFileSync5(path, "utf8"));
|
|
2333
3091
|
} catch {
|
|
2334
3092
|
throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
|
|
2335
3093
|
}
|
|
@@ -2356,11 +3114,11 @@ function componentSlugs(bundle) {
|
|
|
2356
3114
|
});
|
|
2357
3115
|
}
|
|
2358
3116
|
function assertReplaceable(outDir, cwd) {
|
|
2359
|
-
const rel =
|
|
2360
|
-
if (rel === "" || rel.startsWith("..") ||
|
|
3117
|
+
const rel = relative2(resolve2(cwd), resolve2(outDir));
|
|
3118
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
|
|
2361
3119
|
throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
|
|
2362
3120
|
}
|
|
2363
|
-
if (
|
|
3121
|
+
if (existsSync5(outDir) && !existsSync5(join4(outDir, "manifest.json")) && readdirSync2(outDir).length > 0) {
|
|
2364
3122
|
throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
|
|
2365
3123
|
}
|
|
2366
3124
|
}
|
|
@@ -2369,44 +3127,62 @@ function writeBundleFiles(opts) {
|
|
|
2369
3127
|
const selection = opts.selection ?? DEFAULT_SELECTION;
|
|
2370
3128
|
const selected = selectComponents(opts.bundle, selection);
|
|
2371
3129
|
const slugs = componentSlugs(opts.bundle);
|
|
3130
|
+
const outputs = opts.outputs ?? [];
|
|
3131
|
+
const willWriteFoundation = Boolean(opts.bundle.foundation) && selection.foundation;
|
|
3132
|
+
if (willWriteFoundation) {
|
|
3133
|
+
for (const o of outputs) {
|
|
3134
|
+
const problem = outputPathProblem(opts.cwd, opts.outDir, o);
|
|
3135
|
+
if (problem) throw new Error(problem);
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
2372
3138
|
const staging = `${opts.outDir}.partial`;
|
|
2373
|
-
|
|
3139
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
2374
3140
|
const written = [];
|
|
3141
|
+
const deliverables = [];
|
|
3142
|
+
const json = (v) => `${JSON.stringify(v, null, 2)}
|
|
3143
|
+
`;
|
|
2375
3144
|
const put = (rel, content) => {
|
|
2376
|
-
const path =
|
|
2377
|
-
|
|
2378
|
-
|
|
3145
|
+
const path = join4(staging, rel);
|
|
3146
|
+
mkdirSync2(dirname2(path), { recursive: true });
|
|
3147
|
+
writeFileSync4(path, content);
|
|
2379
3148
|
written.push(rel);
|
|
2380
3149
|
};
|
|
2381
3150
|
try {
|
|
2382
3151
|
put("bundle.json", opts.raw);
|
|
2383
3152
|
const artifacts = [];
|
|
2384
3153
|
if (opts.bundle.foundation) {
|
|
2385
|
-
let
|
|
3154
|
+
let path = null;
|
|
2386
3155
|
if (selection.foundation) {
|
|
2387
3156
|
const artifact = opts.bundle.foundation.artifact;
|
|
2388
3157
|
if (validateLevel1(artifact).some((d) => d.severity === "error")) {
|
|
2389
3158
|
throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
|
|
2390
3159
|
}
|
|
2391
|
-
const
|
|
2392
|
-
for (const [name, text] of Object.entries(
|
|
2393
|
-
|
|
3160
|
+
const exp = foundationDtcg(artifact, opts.dtcg ?? {});
|
|
3161
|
+
for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
|
|
3162
|
+
path = "tokens/resolver.json";
|
|
3163
|
+
const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
|
|
3164
|
+
for (const output of outputs) {
|
|
3165
|
+
const rendered = renderOutput(exp, output, header);
|
|
3166
|
+
put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
|
|
3167
|
+
put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
|
|
3168
|
+
deliverables.push({ output, text: rendered.text });
|
|
3169
|
+
}
|
|
2394
3170
|
}
|
|
2395
3171
|
artifacts.push({
|
|
2396
3172
|
kind: "foundation",
|
|
2397
3173
|
name: "foundation",
|
|
2398
3174
|
contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash,
|
|
2399
|
-
|
|
3175
|
+
path
|
|
2400
3176
|
});
|
|
2401
3177
|
}
|
|
2402
3178
|
opts.bundle.components.forEach((component, i) => {
|
|
2403
|
-
const
|
|
2404
|
-
if (
|
|
3179
|
+
const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
|
|
3180
|
+
if (path) put(path, component.ai);
|
|
2405
3181
|
artifacts.push({
|
|
2406
3182
|
kind: "component",
|
|
2407
3183
|
name: component.name,
|
|
2408
3184
|
contentHash: component.artifact.spec_layer.export.content_hash,
|
|
2409
|
-
|
|
3185
|
+
path
|
|
2410
3186
|
});
|
|
2411
3187
|
});
|
|
2412
3188
|
const manifest = {
|
|
@@ -2417,23 +3193,29 @@ function writeBundleFiles(opts) {
|
|
|
2417
3193
|
extractorVersion: opts.bundle.extractorVersion,
|
|
2418
3194
|
selection,
|
|
2419
3195
|
artifacts,
|
|
2420
|
-
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
|
|
3196
|
+
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
|
|
3197
|
+
...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
|
|
3198
|
+
...opts.outputs ? { outputs: opts.outputs } : {}
|
|
2421
3199
|
};
|
|
2422
|
-
put("manifest.json",
|
|
2423
|
-
`);
|
|
3200
|
+
put("manifest.json", json(manifest));
|
|
2424
3201
|
} catch (err) {
|
|
2425
|
-
|
|
3202
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
2426
3203
|
throw err;
|
|
2427
3204
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3205
|
+
rmSync2(opts.outDir, { recursive: true, force: true });
|
|
3206
|
+
renameSync2(staging, opts.outDir);
|
|
3207
|
+
const outputPaths = [];
|
|
3208
|
+
for (const d of deliverables) {
|
|
3209
|
+
writeOutputFile(opts.cwd, d.output, d.text);
|
|
3210
|
+
outputPaths.push(d.output.path);
|
|
3211
|
+
}
|
|
3212
|
+
return { written, outputs: outputPaths };
|
|
2431
3213
|
}
|
|
2432
3214
|
|
|
2433
3215
|
// src/gitignore.ts
|
|
2434
|
-
import { readFileSync as
|
|
3216
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
|
|
2435
3217
|
import { spawnSync } from "node:child_process";
|
|
2436
|
-
import { join as
|
|
3218
|
+
import { join as join5, dirname as dirname3, resolve as resolve3 } from "node:path";
|
|
2437
3219
|
var COMMENT = "# Spec Layer pull key, not for committing";
|
|
2438
3220
|
function git(cwd, args) {
|
|
2439
3221
|
const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
@@ -2441,10 +3223,10 @@ function git(cwd, args) {
|
|
|
2441
3223
|
return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
|
|
2442
3224
|
}
|
|
2443
3225
|
function insideWorkTreeWithoutGit(cwd) {
|
|
2444
|
-
let dir =
|
|
3226
|
+
let dir = resolve3(cwd);
|
|
2445
3227
|
for (; ; ) {
|
|
2446
|
-
if (
|
|
2447
|
-
const parent =
|
|
3228
|
+
if (existsSync6(join5(dir, ".git"))) return true;
|
|
3229
|
+
const parent = dirname3(dir);
|
|
2448
3230
|
if (parent === dir) return false;
|
|
2449
3231
|
dir = parent;
|
|
2450
3232
|
}
|
|
@@ -2460,18 +3242,18 @@ function ensureIgnored(cwd, fileName) {
|
|
|
2460
3242
|
if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
|
|
2461
3243
|
const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
|
|
2462
3244
|
if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
|
|
2463
|
-
const path =
|
|
2464
|
-
const existed =
|
|
3245
|
+
const path = join5(cwd, ".gitignore");
|
|
3246
|
+
const existed = existsSync6(path);
|
|
2465
3247
|
try {
|
|
2466
3248
|
if (!existed) {
|
|
2467
|
-
|
|
3249
|
+
writeFileSync5(path, `${COMMENT}
|
|
2468
3250
|
${fileName}
|
|
2469
3251
|
`);
|
|
2470
3252
|
} else {
|
|
2471
|
-
const body =
|
|
3253
|
+
const body = readFileSync6(path, "utf8");
|
|
2472
3254
|
if (!hasEntryLine(body, fileName)) {
|
|
2473
3255
|
const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
|
|
2474
|
-
|
|
3256
|
+
writeFileSync5(path, `${body}${lead}${COMMENT}
|
|
2475
3257
|
${fileName}
|
|
2476
3258
|
`);
|
|
2477
3259
|
}
|
|
@@ -2484,6 +3266,524 @@ ${fileName}
|
|
|
2484
3266
|
return existed ? { kind: "added" } : { kind: "created" };
|
|
2485
3267
|
}
|
|
2486
3268
|
|
|
3269
|
+
// src/skill.ts
|
|
3270
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3271
|
+
import { dirname as dirname4, join as join6 } from "node:path";
|
|
3272
|
+
|
|
3273
|
+
// src/tools.ts
|
|
3274
|
+
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
3275
|
+
var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
|
|
3276
|
+
var TOOLS = [
|
|
3277
|
+
{
|
|
3278
|
+
name: "setup",
|
|
3279
|
+
usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3280
|
+
summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
|
|
3281
|
+
when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
|
|
3282
|
+
network: true,
|
|
3283
|
+
needsKey: true,
|
|
3284
|
+
writes: [
|
|
3285
|
+
"speclayer.json",
|
|
3286
|
+
"speclayer.local.json",
|
|
3287
|
+
".gitignore (one line, when inside a git repo)",
|
|
3288
|
+
"<outDir>/",
|
|
3289
|
+
"outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"
|
|
3290
|
+
],
|
|
3291
|
+
exits: OK_OR_ERROR
|
|
3292
|
+
},
|
|
3293
|
+
{
|
|
3294
|
+
name: "init",
|
|
3295
|
+
usage: "spec-layer init --id lib_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3296
|
+
summary: "Writes speclayer.json, with the platforms and default outputs, so later commands need no flags. Stores no key and reaches no server.",
|
|
3297
|
+
when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
|
|
3298
|
+
network: false,
|
|
3299
|
+
needsKey: false,
|
|
3300
|
+
writes: ["speclayer.json"],
|
|
3301
|
+
exits: { "0": "success", "1": "usage error" }
|
|
3302
|
+
},
|
|
3303
|
+
{
|
|
3304
|
+
name: "pull",
|
|
3305
|
+
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3306
|
+
summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
|
|
3307
|
+
when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block, or the outputs block.",
|
|
3308
|
+
network: true,
|
|
3309
|
+
needsKey: true,
|
|
3310
|
+
writes: ["<outDir>/", "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"],
|
|
3311
|
+
exits: OK_OR_ERROR
|
|
3312
|
+
},
|
|
3313
|
+
{
|
|
3314
|
+
name: "status",
|
|
3315
|
+
usage: "spec-layer status [--id lib_...] [--key sl_...] [--out DIR]",
|
|
3316
|
+
summary: "Checks whether the local pull is current without writing anything.",
|
|
3317
|
+
when: "Before reading the pulled files, or in CI; exit 2 means run pull.",
|
|
3318
|
+
network: true,
|
|
3319
|
+
needsKey: true,
|
|
3320
|
+
writes: [],
|
|
3321
|
+
exits: { "0": "up to date", "1": "usage error, bad key or id, or a network or server failure", "2": "behind, or no local pull yet" }
|
|
3322
|
+
},
|
|
3323
|
+
{
|
|
3324
|
+
name: "list",
|
|
3325
|
+
usage: "spec-layer list [--out DIR]",
|
|
3326
|
+
summary: 'Lists every artifact in the last pull with its file path, or "not written" when the selection skipped it.',
|
|
3327
|
+
when: "To learn which components the library documents and where each file is.",
|
|
3328
|
+
network: false,
|
|
3329
|
+
needsKey: false,
|
|
3330
|
+
writes: [],
|
|
3331
|
+
exits: LOCAL_ONLY
|
|
3332
|
+
},
|
|
3333
|
+
{
|
|
3334
|
+
name: "show",
|
|
3335
|
+
usage: "spec-layer show foundation | component NAME [--canonical] [--out DIR]",
|
|
3336
|
+
summary: "Prints one artifact to stdout: the Foundation DTCG document, or one component's AI YAML; --canonical prints the v5 JSON.",
|
|
3337
|
+
when: "To read one component or the token document without opening files; it pipes cleanly.",
|
|
3338
|
+
network: false,
|
|
3339
|
+
needsKey: false,
|
|
3340
|
+
writes: [],
|
|
3341
|
+
exits: LOCAL_ONLY
|
|
3342
|
+
},
|
|
3343
|
+
{
|
|
3344
|
+
name: "tools",
|
|
3345
|
+
usage: "spec-layer tools [--json]",
|
|
3346
|
+
summary: "Prints this list of commands, with what each reaches and writes.",
|
|
3347
|
+
when: "A coding agent deciding which command to run; --json is stable for machines.",
|
|
3348
|
+
network: false,
|
|
3349
|
+
needsKey: false,
|
|
3350
|
+
writes: [],
|
|
3351
|
+
exits: { "0": "success" }
|
|
3352
|
+
},
|
|
3353
|
+
{
|
|
3354
|
+
name: "skill",
|
|
3355
|
+
usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter]... [--json] [--out DIR]",
|
|
3356
|
+
summary: "Prints a guide for a coding agent, adapted to this repository's stack and to the last pull; --install writes it where the agent reads instructions.",
|
|
3357
|
+
when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
|
|
3358
|
+
network: false,
|
|
3359
|
+
needsKey: false,
|
|
3360
|
+
writes: ["agent instruction files (only with --install; each path is printed)"],
|
|
3361
|
+
exits: { "0": "success", "1": "usage error, or a file could not be written" }
|
|
3362
|
+
}
|
|
3363
|
+
];
|
|
3364
|
+
var GLOBAL_FLAGS = [
|
|
3365
|
+
{ flag: "--api URL", summary: "Override the API origin (default https://api.spec-layer.com). Also SPEC_LAYER_API." },
|
|
3366
|
+
{ flag: "--out DIR", summary: "Output directory (default .speclayer, or the outDir in speclayer.json)." }
|
|
3367
|
+
];
|
|
3368
|
+
var KEY_RESOLUTION = "The pull key resolves from --key, then SPEC_LAYER_KEY, then speclayer.local.json written by setup. No command ever prints it.";
|
|
3369
|
+
function toolsText() {
|
|
3370
|
+
const lines = ["spec-layer commands", ""];
|
|
3371
|
+
for (const tool of TOOLS) {
|
|
3372
|
+
lines.push(tool.usage);
|
|
3373
|
+
lines.push(` ${tool.summary}`);
|
|
3374
|
+
lines.push(` When: ${tool.when}`);
|
|
3375
|
+
lines.push(` Network: ${tool.network ? "yes" : "no"}. Key: ${tool.needsKey ? "required" : "not needed"}. Writes: ${tool.writes.length ? tool.writes.join(", ") : "nothing"}.`);
|
|
3376
|
+
lines.push(` Exits: ${Object.entries(tool.exits).map(([code2, meaning]) => `${code2} ${meaning}`).join("; ")}.`);
|
|
3377
|
+
lines.push("");
|
|
3378
|
+
}
|
|
3379
|
+
lines.push("Flags every command accepts where they apply:");
|
|
3380
|
+
for (const f of GLOBAL_FLAGS) lines.push(` ${f.flag} ${f.summary}`);
|
|
3381
|
+
lines.push("");
|
|
3382
|
+
lines.push(KEY_RESOLUTION);
|
|
3383
|
+
return lines.join("\n");
|
|
3384
|
+
}
|
|
3385
|
+
function toolsJson(version) {
|
|
3386
|
+
return `${JSON.stringify({
|
|
3387
|
+
cli: "spec-layer",
|
|
3388
|
+
version,
|
|
3389
|
+
tools: TOOLS.map((t) => ({ ...t })),
|
|
3390
|
+
flags: GLOBAL_FLAGS,
|
|
3391
|
+
key_resolution: KEY_RESOLUTION
|
|
3392
|
+
}, null, 2)}
|
|
3393
|
+
`;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// src/skill.ts
|
|
3397
|
+
var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
|
|
3398
|
+
function countNumberTokens(tree) {
|
|
3399
|
+
if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return 0;
|
|
3400
|
+
const record = tree;
|
|
3401
|
+
if (record.$type === "number" && "$value" in record) return 1;
|
|
3402
|
+
let n = 0;
|
|
3403
|
+
for (const [key, value] of Object.entries(record)) {
|
|
3404
|
+
if (key.startsWith("$")) continue;
|
|
3405
|
+
n += countNumberTokens(value);
|
|
3406
|
+
}
|
|
3407
|
+
return n;
|
|
3408
|
+
}
|
|
3409
|
+
function readJson(path) {
|
|
3410
|
+
if (!existsSync7(path)) return null;
|
|
3411
|
+
try {
|
|
3412
|
+
return JSON.parse(readFileSync7(path, "utf8"));
|
|
3413
|
+
} catch {
|
|
3414
|
+
return null;
|
|
3415
|
+
}
|
|
3416
|
+
}
|
|
3417
|
+
function summarizePull(cwd, outDir, manifest) {
|
|
3418
|
+
if (!manifest) return null;
|
|
3419
|
+
const absOut = join6(cwd, outDir);
|
|
3420
|
+
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path ? `${outDir}/${a.path}` : null }));
|
|
3421
|
+
const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
|
|
3422
|
+
let foundation = null;
|
|
3423
|
+
if (foundationEntry) {
|
|
3424
|
+
const tokensDir = join6(absOut, "tokens");
|
|
3425
|
+
const resolver = readJson(join6(tokensDir, "resolver.json"));
|
|
3426
|
+
const report2 = readJson(join6(tokensDir, "report.json"));
|
|
3427
|
+
let tokenFiles = [];
|
|
3428
|
+
try {
|
|
3429
|
+
tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
|
|
3430
|
+
} catch {
|
|
3431
|
+
tokenFiles = [];
|
|
3432
|
+
}
|
|
3433
|
+
let unitlessNumbers = 0;
|
|
3434
|
+
for (const file of tokenFiles) {
|
|
3435
|
+
if (file.startsWith("styles.")) continue;
|
|
3436
|
+
unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
|
|
3437
|
+
}
|
|
3438
|
+
const reportCounts = {};
|
|
3439
|
+
if (Array.isArray(report2)) {
|
|
3440
|
+
for (const entry2 of report2) {
|
|
3441
|
+
if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
foundation = {
|
|
3445
|
+
written: foundationEntry.path !== null && resolver !== null,
|
|
3446
|
+
sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
|
|
3447
|
+
modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
|
|
3448
|
+
name,
|
|
3449
|
+
contexts: Object.keys(m.contexts ?? {}),
|
|
3450
|
+
default: m.default ?? null
|
|
3451
|
+
})) : [],
|
|
3452
|
+
tokenFiles,
|
|
3453
|
+
unitlessNumbers,
|
|
3454
|
+
reportCounts
|
|
3455
|
+
};
|
|
3456
|
+
}
|
|
3457
|
+
return {
|
|
3458
|
+
outDir,
|
|
3459
|
+
libraryId: manifest.libraryId,
|
|
3460
|
+
publishedAt: manifest.publishedAt,
|
|
3461
|
+
pluginVersion: manifest.pluginVersion,
|
|
3462
|
+
components,
|
|
3463
|
+
foundation,
|
|
3464
|
+
outputs: (manifest.outputs ?? []).map((o) => ({
|
|
3465
|
+
platform: o.platform,
|
|
3466
|
+
format: o.format,
|
|
3467
|
+
path: o.path,
|
|
3468
|
+
case: o.case,
|
|
3469
|
+
modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
|
|
3470
|
+
modes: o.modes ?? {},
|
|
3471
|
+
// manifest.outputs records the configured list regardless of whether the
|
|
3472
|
+
// Foundation was written; the map file exists only when it was actually
|
|
3473
|
+
// rendered, so it is the on-disk proof a sentence can point to.
|
|
3474
|
+
written: existsSync7(join6(absOut, "outputs", `${o.platform}-${o.format}.map.json`))
|
|
3475
|
+
}))
|
|
3476
|
+
};
|
|
3477
|
+
}
|
|
3478
|
+
var code = (s) => `\`${s}\``;
|
|
3479
|
+
function stackSection(input) {
|
|
3480
|
+
const { profile, platforms, platformSource, pull } = input;
|
|
3481
|
+
const lines = ["## This codebase", ""];
|
|
3482
|
+
if (profile.evidence.length === 0) {
|
|
3483
|
+
lines.push(
|
|
3484
|
+
"Nothing at the root of this directory identified a language, framework, or platform. "
|
|
3485
|
+
);
|
|
3486
|
+
} else {
|
|
3487
|
+
lines.push("Detected from the repository root (the file that carries each signal is named, and nothing deeper was read):", "");
|
|
3488
|
+
for (const e of profile.evidence) lines.push(`- ${e.signal} (${code(e.file)})`);
|
|
3489
|
+
lines.push("");
|
|
3490
|
+
const facts = [];
|
|
3491
|
+
if (profile.languages.length) facts.push(`Languages: ${profile.languages.join(", ")}.`);
|
|
3492
|
+
if (profile.frameworks.length) facts.push(`Frameworks: ${profile.frameworks.join(", ")}.`);
|
|
3493
|
+
if (profile.tokenTools.length) facts.push(`Token tooling: ${profile.tokenTools.join(", ")}.`);
|
|
3494
|
+
if (facts.length) lines.push(facts.join(" "), "");
|
|
3495
|
+
}
|
|
3496
|
+
if (platformSource === "none") {
|
|
3497
|
+
lines.push(
|
|
3498
|
+
`No target platform was detected, so the token advice below is generic. Re-run ${code("spec-layer skill --platform web|ios|android|flutter")} to write it for a platform, or pass ` + code("--install") + " with the same flag to update the installed copy.",
|
|
3499
|
+
""
|
|
3500
|
+
);
|
|
3501
|
+
} else {
|
|
3502
|
+
const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
|
|
3503
|
+
lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
|
|
3504
|
+
}
|
|
3505
|
+
for (const platform of platforms) {
|
|
3506
|
+
const key = CODE_SYNTAX_KEY[platform];
|
|
3507
|
+
const tokensDir = `${input.outDir}/tokens/`;
|
|
3508
|
+
if (platform === "web") {
|
|
3509
|
+
lines.push("### Web", "");
|
|
3510
|
+
const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
|
|
3511
|
+
if (cssOut) {
|
|
3512
|
+
const mapPath = `${input.outDir}/outputs/web-css.map.json`;
|
|
3513
|
+
lines.push(
|
|
3514
|
+
`The CSS custom property for every token is in ${code(mapPath)}: source "code_syntax" when the designer declared it in Figma, "derived" when the CLI built it from the DTCG path by the stated rule (${cssOut.case} case, collection root included). Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
|
|
3515
|
+
""
|
|
3516
|
+
);
|
|
3517
|
+
lines.push(
|
|
3518
|
+
`Import ${code(cssOut.path)} from the root stylesheet. It holds every set and every default mode at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)}. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). Wire it to prefers-color-scheme yourself if the OS should choose; the file never assumes that.`,
|
|
3519
|
+
""
|
|
3520
|
+
);
|
|
3521
|
+
if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
|
|
3522
|
+
lines.push(
|
|
3523
|
+
`${code(cssOut.path)} is a projection of the same ${code("tokens/")} files, not a second source. Import one or the other.`,
|
|
3524
|
+
""
|
|
3525
|
+
);
|
|
3526
|
+
}
|
|
3527
|
+
} else {
|
|
3528
|
+
lines.push(
|
|
3529
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. When a token has no WEB entry, use the DTCG path as it appears in the token file and say in your change that the code name is not declared in Figma.`,
|
|
3530
|
+
""
|
|
3531
|
+
);
|
|
3532
|
+
const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
|
|
3533
|
+
if (configuredNotWritten) {
|
|
3534
|
+
lines.push(
|
|
3535
|
+
`A web/css output is configured at ${code(configuredNotWritten.path)} but was not written, because the last pull did not write the Foundation. Pull with the Foundation selected to write it.`,
|
|
3536
|
+
""
|
|
3537
|
+
);
|
|
3538
|
+
} else if (pull?.foundation?.written) {
|
|
3539
|
+
lines.push(
|
|
3540
|
+
`No token file was written for web. Add \`"outputs"\` in \`speclayer.json\` (or run \`spec-layer pull --platform web\` once) and pull again; the default lands at ${code("spec-layer/tokens.css")}.`,
|
|
3541
|
+
""
|
|
3542
|
+
);
|
|
3543
|
+
}
|
|
3544
|
+
}
|
|
3545
|
+
if (profile.tokenTools.includes("tailwind")) {
|
|
3546
|
+
lines.push(
|
|
3547
|
+
`Tailwind is present (${code("tailwindcss")}). Map DTCG ${code("color")} tokens to the theme's color scale and ${code("dimension")} tokens to spacing, radius, or font size by the collection and group they sit in. Keep the mapping in one place and reference token paths, not copied values, so a republish moves the code with it.`,
|
|
3548
|
+
""
|
|
3549
|
+
);
|
|
3550
|
+
}
|
|
3551
|
+
if (profile.tokenTools.includes("style-dictionary")) {
|
|
3552
|
+
const major = profile.styleDictionaryMajor;
|
|
3553
|
+
lines.push(
|
|
3554
|
+
`Style Dictionary is present${major !== null ? ` (major version ${major} in package.json)` : ""}. Point it at ${code(tokensDir)} and load the files ${code("resolver.json")} names for the mode you build. Exclude ${code("spec-layer.meta.json")} and ${code("report.json")} from token globs; they are not token files.`
|
|
3555
|
+
);
|
|
3556
|
+
if (major !== null && major < 5) {
|
|
3557
|
+
lines.push(
|
|
3558
|
+
"",
|
|
3559
|
+
`Style Dictionary ${major} reads the string value forms, not the 2025.10 object forms. Set ${code('"dtcg": { "values": "legacy" }')} in ${code("speclayer.json")} and run ${code("spec-layer pull")}; the change re-projects tokens/ without a republish.`
|
|
3560
|
+
);
|
|
3561
|
+
}
|
|
3562
|
+
lines.push("");
|
|
3563
|
+
}
|
|
3564
|
+
} else if (platform === "ios") {
|
|
3565
|
+
lines.push("### iOS", "");
|
|
3566
|
+
lines.push(
|
|
3567
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Swift symbol. Colors arrive as hex strings or DTCG color objects with RGBA components; dimensions carry an explicit px or rem unit. A modifier with more than one context (for example a light and a dark mode) maps to a color-scheme-dependent value; a set without modes is a constant. Do not invent a dark variant for a collection that has one mode.`,
|
|
3568
|
+
""
|
|
3569
|
+
);
|
|
3570
|
+
} else if (platform === "android") {
|
|
3571
|
+
lines.push("### Android", "");
|
|
3572
|
+
lines.push(
|
|
3573
|
+
`Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. Use that as the Kotlin or resource name. Dimensions carry an explicit px or rem unit and no density assumption; a value is only dp when your own convention says so, and that convention belongs in your code, not in the token file. Modes map to resource qualifiers or a Compose theme switch.`,
|
|
3574
|
+
""
|
|
3575
|
+
);
|
|
3576
|
+
} else {
|
|
3577
|
+
lines.push("### Flutter", "");
|
|
3578
|
+
lines.push(
|
|
3579
|
+
`Figma declares no code syntax for Flutter, so no identifier is provided for Dart. Name symbols after the DTCG path (for example ${code("Collection.group.name")} becomes a nested class or a camelCase constant) and keep the path in a comment so the source token stays traceable. Modes map to theme variants.`,
|
|
3580
|
+
""
|
|
3581
|
+
);
|
|
3582
|
+
}
|
|
3583
|
+
}
|
|
3584
|
+
if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
|
|
3585
|
+
const n = pull.foundation.unitlessNumbers;
|
|
3586
|
+
lines.push(
|
|
3587
|
+
`${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")}.`,
|
|
3588
|
+
""
|
|
3589
|
+
);
|
|
3590
|
+
}
|
|
3591
|
+
return lines;
|
|
3592
|
+
}
|
|
3593
|
+
function pullSection(input) {
|
|
3594
|
+
const { pull, outDir } = input;
|
|
3595
|
+
const lines = ["## What is on disk", ""];
|
|
3596
|
+
if (!pull) {
|
|
3597
|
+
lines.push(
|
|
3598
|
+
`No pull has been made in this directory yet, so nothing under ${code(outDir + "/")} can be described. Run ${code("npx spec-layer pull")} (or the setup command from the plugin if there is no ${code("speclayer.json")}), then ${code("npx spec-layer skill --install")} again to list the components and token collections here.`,
|
|
3599
|
+
""
|
|
3600
|
+
);
|
|
3601
|
+
return lines;
|
|
3602
|
+
}
|
|
3603
|
+
lines.push(
|
|
3604
|
+
`Library ${code(pull.libraryId)}, published ${pull.publishedAt}${pull.pluginVersion ? ` by plugin ${pull.pluginVersion}` : ""}. Run ${code("npx spec-layer status")} first; exit code 2 means a newer publish exists and ${code("npx spec-layer pull")} fetches it.`,
|
|
3605
|
+
""
|
|
3606
|
+
);
|
|
3607
|
+
lines.push(`- ${code(`${outDir}/manifest.json`)}: every artifact with its content hash and file path.`);
|
|
3608
|
+
lines.push(`- ${code(`${outDir}/bundle.json`)}: the whole published library, including the canonical v5 JSON of every artifact.`);
|
|
3609
|
+
if (pull.foundation) {
|
|
3610
|
+
if (pull.foundation.written) {
|
|
3611
|
+
lines.push(`- ${code(`${outDir}/tokens/`)}: the Foundation as Design Tokens Format Module 2025.10 files.`);
|
|
3612
|
+
lines.push(` - ${code("resolver.json")}: sets, modifiers, and resolution order. Start here.`);
|
|
3613
|
+
lines.push(` - ${code("spec-layer.meta.json")}: Figma ids, scopes, publication, and ${code("code_syntax")} per DTCG path.`);
|
|
3614
|
+
lines.push(` - ${code("report.json")}: what the format could not express, with reasons. Never fill these gaps with a guess.`);
|
|
3615
|
+
for (const f of pull.foundation.tokenFiles) lines.push(` - ${code(f)}`);
|
|
3616
|
+
} else {
|
|
3617
|
+
lines.push(`- Foundation: present in the library but not written, because the selection excludes it. ${code("spec-layer show foundation")} still prints it.`);
|
|
3618
|
+
}
|
|
3619
|
+
} else {
|
|
3620
|
+
lines.push("- This library has no Foundation, so there is no tokens/ directory.");
|
|
3621
|
+
}
|
|
3622
|
+
lines.push(`- ${code(`${outDir}/components/`)}: one YAML per component.`);
|
|
3623
|
+
for (const o of pull.outputs) {
|
|
3624
|
+
lines.push(o.written ? `- ${code(o.path)}: ${o.platform}/${o.format} token file, ${o.case} names, modes under ${code(o.modeSelector)}. Names and provenance: ${code(`${outDir}/outputs/${o.platform}-${o.format}.map.json`)}; what it could not express: ${code(`${outDir}/outputs/${o.platform}-${o.format}.report.json`)}.` : `- ${code(o.path)}: ${o.platform}/${o.format} token file, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
|
|
3625
|
+
}
|
|
3626
|
+
lines.push("");
|
|
3627
|
+
if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
|
|
3628
|
+
lines.push("### Token collections", "");
|
|
3629
|
+
for (const set of pull.foundation.sets) lines.push(`- ${code(set)}: one mode, always applied.`);
|
|
3630
|
+
for (const m of pull.foundation.modifiers) {
|
|
3631
|
+
lines.push(`- ${code(m.name)}: modes ${m.contexts.map(code).join(", ")}${m.default ? `, default ${code(m.default)}` : ""}.`);
|
|
3632
|
+
}
|
|
3633
|
+
lines.push("");
|
|
3634
|
+
const counts = Object.entries(pull.foundation.reportCounts);
|
|
3635
|
+
if (counts.length) {
|
|
3636
|
+
lines.push(
|
|
3637
|
+
`${code("report.json")} lists ${counts.map(([c, n]) => `${n} ${code(c)}`).join(", ")}. Read it before assuming a token is missing.`,
|
|
3638
|
+
""
|
|
3639
|
+
);
|
|
3640
|
+
}
|
|
3641
|
+
}
|
|
3642
|
+
lines.push("### Components", "");
|
|
3643
|
+
if (pull.components.length === 0) {
|
|
3644
|
+
lines.push("The library documents no components.", "");
|
|
3645
|
+
} else {
|
|
3646
|
+
for (const c of pull.components) {
|
|
3647
|
+
lines.push(c.path ? `- ${c.name}: ${code(c.path)}` : `- ${c.name}: not written (excluded by the selection). ${code(`spec-layer show component "${c.name}"`)} prints it.`);
|
|
3648
|
+
}
|
|
3649
|
+
lines.push("");
|
|
3650
|
+
}
|
|
3651
|
+
return lines;
|
|
3652
|
+
}
|
|
3653
|
+
function commandsSection() {
|
|
3654
|
+
const lines = ["## Commands", ""];
|
|
3655
|
+
const cell = (s) => s.replace(/\|/g, "\\|");
|
|
3656
|
+
lines.push("| Command | What it does | When | Network | Key | Writes |", "|---|---|---|---|---|---|");
|
|
3657
|
+
for (const t of TOOLS) {
|
|
3658
|
+
lines.push(`| ${code(cell(t.usage))} | ${cell(t.summary)} | ${cell(t.when)} | ${t.network ? "yes" : "no"} | ${t.needsKey ? "required" : "no"} | ${t.writes.length ? t.writes.map((w) => code(cell(w))).join(", ") : "nothing"} |`);
|
|
3659
|
+
}
|
|
3660
|
+
lines.push("");
|
|
3661
|
+
lines.push("Exit codes:", "");
|
|
3662
|
+
for (const t of TOOLS) {
|
|
3663
|
+
lines.push(`- ${code(t.name)}: ${Object.entries(t.exits).map(([c, m]) => `${c} = ${m}`).join("; ")}.`);
|
|
3664
|
+
}
|
|
3665
|
+
lines.push("");
|
|
3666
|
+
for (const f of GLOBAL_FLAGS) lines.push(`- ${code(f.flag)}: ${f.summary}`);
|
|
3667
|
+
lines.push("", KEY_RESOLUTION, "");
|
|
3668
|
+
lines.push(`Run ${code("npx --yes spec-layer <command>")} in an unattended session so npx does not stop to ask before downloading the package. ${code("spec-layer tools --json")} prints this table for machines.`, "");
|
|
3669
|
+
return lines;
|
|
3670
|
+
}
|
|
3671
|
+
function buildSkillGuide(input) {
|
|
3672
|
+
const { outDir } = input;
|
|
3673
|
+
const lines = [];
|
|
3674
|
+
lines.push("# Spec Layer: design-system context for this repository", "");
|
|
3675
|
+
lines.push(
|
|
3676
|
+
`The Spec Layer Figma plugin publishes a design system's components, variables, and styles as data. The ${code("spec-layer")} CLI (version ${input.version}) pulls that data into this repository under ${code(outDir + "/")}. Everything in those files is extracted deterministically from Figma and validated against a published schema; no model wrote any of it. Treat it as the source of truth for what the design system contains, and treat anything it does not state as unknown rather than as something to infer.`,
|
|
3677
|
+
""
|
|
3678
|
+
);
|
|
3679
|
+
lines.push("## How to use it", "");
|
|
3680
|
+
lines.push(`1. Run ${code("npx spec-layer status")}. Exit 0 means the local copy is current; exit 2 means run ${code("npx spec-layer pull")} first.`);
|
|
3681
|
+
lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
|
|
3682
|
+
lines.push(`3. Working with colors, spacing, type, or effects: start at ${code(`${outDir}/tokens/resolver.json`)}, load the set and mode files it names, and look up ${code("code_syntax")} in ${code("spec-layer.meta.json")} for the name the designer declared for your platform.`);
|
|
3683
|
+
lines.push(`4. Reference tokens by name in code; never paste a resolved value where a token exists. A value the design system does not define is not a token: say so in your change rather than adding one.`);
|
|
3684
|
+
lines.push(`5. An ${code("unbound")} entry is design debt reported from Figma. Do not silently promote it to a token; keep the literal and note that Figma has no binding for it.`);
|
|
3685
|
+
const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
|
|
3686
|
+
const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(o.path)).join(", ")} either: pull replaces ${writtenOutputs.length === 1 ? "it" : "them"} in place.` : "";
|
|
3687
|
+
lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
|
|
3688
|
+
lines.push("");
|
|
3689
|
+
lines.push(...pullSection(input));
|
|
3690
|
+
lines.push(...stackSection(input));
|
|
3691
|
+
lines.push(...commandsSection());
|
|
3692
|
+
lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
|
|
3693
|
+
return `${lines.join("\n")}
|
|
3694
|
+
`;
|
|
3695
|
+
}
|
|
3696
|
+
var SKILL_DESCRIPTION = "Use the design-system context the Spec Layer Figma plugin published into this repository: component variants, states, anatomy, token bindings, and design tokens. Read this before building or changing UI, using tokens, or running the spec-layer CLI.";
|
|
3697
|
+
function installTarget(host) {
|
|
3698
|
+
switch (host) {
|
|
3699
|
+
case "claude":
|
|
3700
|
+
return { host, path: ".claude/skills/spec-layer/SKILL.md", mode: "file" };
|
|
3701
|
+
case "cursor":
|
|
3702
|
+
return { host, path: ".cursor/rules/spec-layer.mdc", mode: "file" };
|
|
3703
|
+
case "copilot":
|
|
3704
|
+
return { host, path: ".github/instructions/spec-layer.instructions.md", mode: "file" };
|
|
3705
|
+
case "windsurf":
|
|
3706
|
+
return { host, path: ".windsurf/rules/spec-layer.md", mode: "file" };
|
|
3707
|
+
case "gemini":
|
|
3708
|
+
return { host, path: "GEMINI.md", mode: "block" };
|
|
3709
|
+
case "agents-md":
|
|
3710
|
+
return { host, path: "AGENTS.md", mode: "block" };
|
|
3711
|
+
}
|
|
3712
|
+
}
|
|
3713
|
+
function renderForHost(host, guide) {
|
|
3714
|
+
const yamlString = (s) => JSON.stringify(s);
|
|
3715
|
+
switch (host) {
|
|
3716
|
+
case "claude":
|
|
3717
|
+
return `---
|
|
3718
|
+
name: spec-layer
|
|
3719
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3720
|
+
---
|
|
3721
|
+
|
|
3722
|
+
${guide}`;
|
|
3723
|
+
case "cursor":
|
|
3724
|
+
return `---
|
|
3725
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3726
|
+
alwaysApply: false
|
|
3727
|
+
---
|
|
3728
|
+
|
|
3729
|
+
${guide}`;
|
|
3730
|
+
case "copilot":
|
|
3731
|
+
return `---
|
|
3732
|
+
applyTo: "**"
|
|
3733
|
+
---
|
|
3734
|
+
|
|
3735
|
+
${guide}`;
|
|
3736
|
+
case "windsurf":
|
|
3737
|
+
return `---
|
|
3738
|
+
trigger: model_decision
|
|
3739
|
+
description: ${yamlString(SKILL_DESCRIPTION)}
|
|
3740
|
+
---
|
|
3741
|
+
|
|
3742
|
+
${guide}`;
|
|
3743
|
+
case "gemini":
|
|
3744
|
+
case "agents-md":
|
|
3745
|
+
return guide;
|
|
3746
|
+
}
|
|
3747
|
+
}
|
|
3748
|
+
var BLOCK_BEGIN = "<!-- spec-layer:begin -->";
|
|
3749
|
+
var BLOCK_END = "<!-- spec-layer:end -->";
|
|
3750
|
+
function upsertBlock(existing, guide) {
|
|
3751
|
+
const block = `${BLOCK_BEGIN}
|
|
3752
|
+
${guide.trimEnd()}
|
|
3753
|
+
${BLOCK_END}
|
|
3754
|
+
`;
|
|
3755
|
+
if (existing === null) return block;
|
|
3756
|
+
const begin = existing.indexOf(BLOCK_BEGIN);
|
|
3757
|
+
const end = existing.indexOf(BLOCK_END);
|
|
3758
|
+
if (begin !== -1 && end !== -1 && end > begin) {
|
|
3759
|
+
const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
|
|
3760
|
+
return `${existing.slice(0, begin)}${block}${after}`;
|
|
3761
|
+
}
|
|
3762
|
+
const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3763
|
+
return `${existing}${sep}${block}`;
|
|
3764
|
+
}
|
|
3765
|
+
function installSkill(cwd, host, guide) {
|
|
3766
|
+
const target = installTarget(host);
|
|
3767
|
+
const abs = join6(cwd, target.path);
|
|
3768
|
+
const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
|
|
3769
|
+
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
3770
|
+
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
3771
|
+
mkdirSync3(dirname4(abs), { recursive: true });
|
|
3772
|
+
writeFileSync6(abs, next);
|
|
3773
|
+
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
// src/version.ts
|
|
3777
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
3778
|
+
function cliVersion() {
|
|
3779
|
+
try {
|
|
3780
|
+
const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
|
|
3781
|
+
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
3782
|
+
} catch {
|
|
3783
|
+
return "unknown";
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
|
|
2487
3787
|
// src/commands.ts
|
|
2488
3788
|
var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
|
|
2489
3789
|
function manifestReader() {
|
|
@@ -2495,8 +3795,8 @@ function manifestReader() {
|
|
|
2495
3795
|
}
|
|
2496
3796
|
function sameOutput(a, b) {
|
|
2497
3797
|
const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
|
|
2498
|
-
const
|
|
2499
|
-
return selectionKey(a.selection) === selectionKey(b.selection) &&
|
|
3798
|
+
const key = (v) => JSON.stringify(sortKeys(v ?? {}));
|
|
3799
|
+
return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []);
|
|
2500
3800
|
}
|
|
2501
3801
|
function sortKeys(value) {
|
|
2502
3802
|
if (Array.isArray(value)) return value.map(sortKeys);
|
|
@@ -2505,6 +3805,35 @@ function sortKeys(value) {
|
|
|
2505
3805
|
}
|
|
2506
3806
|
return value;
|
|
2507
3807
|
}
|
|
3808
|
+
function platformsFromFlags(flags, io2) {
|
|
3809
|
+
if (flags.platform === void 0 || flags.platform.length === 0) return void 0;
|
|
3810
|
+
const out = [];
|
|
3811
|
+
for (const value of flags.platform) {
|
|
3812
|
+
if (!isPlatform(value)) {
|
|
3813
|
+
io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${value}".`);
|
|
3814
|
+
return null;
|
|
3815
|
+
}
|
|
3816
|
+
if (!out.includes(value)) out.push(value);
|
|
3817
|
+
}
|
|
3818
|
+
return out;
|
|
3819
|
+
}
|
|
3820
|
+
function resolvePlatforms(cwd, fromFlags, config) {
|
|
3821
|
+
if (fromFlags) return { platforms: fromFlags, source: "flag" };
|
|
3822
|
+
if (config?.platforms && config.platforms.length > 0) return { platforms: config.platforms, source: "config" };
|
|
3823
|
+
const detected = detectRepo(cwd).platforms;
|
|
3824
|
+
return { platforms: detected, source: detected.length > 0 ? "detected" : "none" };
|
|
3825
|
+
}
|
|
3826
|
+
function outputsForRun(fromFlags, config, platforms) {
|
|
3827
|
+
if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
|
|
3828
|
+
return config?.outputs ?? defaultOutputs(platforms);
|
|
3829
|
+
}
|
|
3830
|
+
var NO_PLATFORM_NOTE = `No target platform detected, so no token file was written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
|
|
3831
|
+
function platformsMissingFormat(platforms) {
|
|
3832
|
+
return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
|
|
3833
|
+
}
|
|
3834
|
+
function missingFormatNote(platforms) {
|
|
3835
|
+
return `No token file exists yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
|
|
3836
|
+
}
|
|
2508
3837
|
var errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
2509
3838
|
function runInit(cwd, flags, io2) {
|
|
2510
3839
|
if (!flags.id) {
|
|
@@ -2518,9 +3847,24 @@ function runInit(cwd, flags, io2) {
|
|
|
2518
3847
|
io2.err(errorText(err));
|
|
2519
3848
|
return 1;
|
|
2520
3849
|
}
|
|
3850
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
3851
|
+
if (fromFlags === null) return 1;
|
|
3852
|
+
const { platforms, source } = resolvePlatforms(cwd, fromFlags, null);
|
|
3853
|
+
const outputs = defaultOutputs(platforms);
|
|
2521
3854
|
const outDir = flags.out ?? DEFAULT_OUT_DIR;
|
|
2522
|
-
writeConfig(cwd, {
|
|
2523
|
-
|
|
3855
|
+
writeConfig(cwd, {
|
|
3856
|
+
libraryId: flags.id,
|
|
3857
|
+
outDir,
|
|
3858
|
+
...include ? { include } : {},
|
|
3859
|
+
...platforms.length > 0 ? { platforms } : {},
|
|
3860
|
+
...outputs.length > 0 ? { outputs } : {}
|
|
3861
|
+
});
|
|
3862
|
+
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
|
|
3863
|
+
for (const o of outputs) io2.out(`Token file for ${o.platform}: ${o.path} (${o.format}, ${o.case} names), written by the next pull.`);
|
|
3864
|
+
if (source === "flag" || source === "detected") {
|
|
3865
|
+
const missing = platformsMissingFormat(platforms);
|
|
3866
|
+
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
3867
|
+
}
|
|
2524
3868
|
io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
|
|
2525
3869
|
return 0;
|
|
2526
3870
|
}
|
|
@@ -2544,7 +3888,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
|
|
|
2544
3888
|
}
|
|
2545
3889
|
function resolvedOutDir(cwd, flags, io2) {
|
|
2546
3890
|
try {
|
|
2547
|
-
return
|
|
3891
|
+
return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
|
|
2548
3892
|
} catch (err) {
|
|
2549
3893
|
io2.err(errorText(err));
|
|
2550
3894
|
return null;
|
|
@@ -2580,16 +3924,22 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
|
|
|
2580
3924
|
} catch {
|
|
2581
3925
|
existing = null;
|
|
2582
3926
|
}
|
|
3927
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
3928
|
+
if (fromFlags === null) return 1;
|
|
2583
3929
|
const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
|
|
2584
3930
|
const keptInclude = include ?? existing?.include ?? null;
|
|
2585
3931
|
const keptDtcg = existing?.dtcg ?? null;
|
|
3932
|
+
const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
|
|
3933
|
+
const outputs = withDefaults(existing?.outputs ?? [], platforms);
|
|
2586
3934
|
writeConfig(cwd, {
|
|
2587
3935
|
libraryId: flags.id,
|
|
2588
3936
|
outDir,
|
|
2589
3937
|
...keptInclude ? { include: keptInclude } : {},
|
|
2590
|
-
...keptDtcg ? { dtcg: keptDtcg } : {}
|
|
3938
|
+
...keptDtcg ? { dtcg: keptDtcg } : {},
|
|
3939
|
+
...platforms.length > 0 ? { platforms } : {},
|
|
3940
|
+
...existing?.outputs !== void 0 || outputs.length > 0 ? { outputs } : {}
|
|
2591
3941
|
});
|
|
2592
|
-
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
|
|
3942
|
+
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
|
|
2593
3943
|
const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
|
|
2594
3944
|
switch (ignored.kind) {
|
|
2595
3945
|
case "refused":
|
|
@@ -2627,7 +3977,14 @@ git rm --cached ${ignored.line}`);
|
|
|
2627
3977
|
}
|
|
2628
3978
|
const { replaced } = writeCredentials(cwd, { libraryId: flags.id, key });
|
|
2629
3979
|
io2.out(replaced ? `Replaced the stored key in ${CREDENTIALS_NAME}.` : `Stored the pull key in ${CREDENTIALS_NAME}.`);
|
|
2630
|
-
|
|
3980
|
+
const code2 = await runPull(cwd, { ...flags, key }, env, io2, fetcher);
|
|
3981
|
+
if (code2 !== 0) return code2;
|
|
3982
|
+
const hosts = detectRepo(cwd).agents;
|
|
3983
|
+
io2.out("");
|
|
3984
|
+
io2.out("Next step for a coding agent: npx spec-layer skill --install");
|
|
3985
|
+
io2.out(hosts.length > 0 ? `That writes a guide to the pulled files, adapted to this codebase, to ${hosts.map((h) => installTarget(h).path).join(", ")}.` : `That writes a guide to the pulled files, adapted to this codebase, into ${installTarget("agents-md").path}; --agent ${AGENT_HOSTS.join("|")} chooses where.`);
|
|
3986
|
+
io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
|
|
3987
|
+
return 0;
|
|
2631
3988
|
}
|
|
2632
3989
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
2633
3990
|
const manifestAt = manifestReader();
|
|
@@ -2640,11 +3997,17 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
2640
3997
|
io2.err(errorText(err));
|
|
2641
3998
|
return 1;
|
|
2642
3999
|
}
|
|
2643
|
-
const
|
|
4000
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4001
|
+
if (fromFlags === null) return 1;
|
|
4002
|
+
const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
|
|
4003
|
+
const outputs = outputsForRun(fromFlags, opts, platforms);
|
|
4004
|
+
const manifest = manifestAt(join7(cwd, opts.outDir));
|
|
4005
|
+
const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
|
|
4006
|
+
const willWriteFoundation = selection.foundation && foundationOnDisk;
|
|
2644
4007
|
const etag = manifest && sameOutput(
|
|
2645
|
-
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
|
|
2646
|
-
{ selection, dtcg: opts.dtcg }
|
|
2647
|
-
) ? manifest.bundleHash : void 0;
|
|
4008
|
+
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs },
|
|
4009
|
+
{ selection, dtcg: opts.dtcg, outputs }
|
|
4010
|
+
) && (!willWriteFoundation || outputs.every((o) => existsSync8(resolve4(cwd, o.path)) && existsSync8(join7(cwd, opts.outDir, "outputs", `${outputId(o)}.map.json`)))) ? manifest.bundleHash : void 0;
|
|
2648
4011
|
const result = await fetchBundle({
|
|
2649
4012
|
api: opts.api,
|
|
2650
4013
|
libraryId: opts.libraryId,
|
|
@@ -2661,11 +4024,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
2661
4024
|
return 0;
|
|
2662
4025
|
}
|
|
2663
4026
|
let written;
|
|
4027
|
+
let outputPaths = [];
|
|
2664
4028
|
try {
|
|
2665
4029
|
const bundle = parseBundle(result.raw);
|
|
2666
4030
|
const selected = selectComponents(bundle, selection);
|
|
2667
|
-
|
|
2668
|
-
outDir:
|
|
4031
|
+
const writeResult = writeBundleFiles({
|
|
4032
|
+
outDir: join7(cwd, opts.outDir),
|
|
2669
4033
|
cwd,
|
|
2670
4034
|
raw: result.raw,
|
|
2671
4035
|
bundle,
|
|
@@ -2673,8 +4037,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
2673
4037
|
libraryId: opts.libraryId,
|
|
2674
4038
|
publishedAt: result.publishedAt,
|
|
2675
4039
|
bundleHash: result.bundleHash,
|
|
2676
|
-
dtcg: opts.dtcg
|
|
4040
|
+
dtcg: opts.dtcg,
|
|
4041
|
+
platforms,
|
|
4042
|
+
outputs
|
|
2677
4043
|
});
|
|
4044
|
+
written = writeResult.written;
|
|
4045
|
+
outputPaths = writeResult.outputs;
|
|
2678
4046
|
io2.out(
|
|
2679
4047
|
`Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
|
|
2680
4048
|
);
|
|
@@ -2683,13 +4051,22 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
2683
4051
|
return 1;
|
|
2684
4052
|
}
|
|
2685
4053
|
io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
|
|
4054
|
+
for (const path of outputPaths) {
|
|
4055
|
+
const o = outputs.find((x) => x.path === path);
|
|
4056
|
+
if (o) io2.out(`Wrote ${path} (${o.platform}/${o.format}, ${o.case} names).`);
|
|
4057
|
+
}
|
|
4058
|
+
if (outputPaths.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
|
|
4059
|
+
if (source === "flag" || source === "config") {
|
|
4060
|
+
const missing = platformsMissingFormat(platforms);
|
|
4061
|
+
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
4062
|
+
}
|
|
2686
4063
|
return 0;
|
|
2687
4064
|
}
|
|
2688
4065
|
async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
2689
4066
|
const manifestAt = manifestReader();
|
|
2690
4067
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
2691
4068
|
if (!opts) return 1;
|
|
2692
|
-
const manifest = manifestAt(
|
|
4069
|
+
const manifest = manifestAt(join7(cwd, opts.outDir));
|
|
2693
4070
|
if (!manifest) {
|
|
2694
4071
|
io2.err(NO_LOCAL_PULL);
|
|
2695
4072
|
return 2;
|
|
@@ -2721,11 +4098,15 @@ function runList(cwd, flags, io2) {
|
|
|
2721
4098
|
return 1;
|
|
2722
4099
|
}
|
|
2723
4100
|
io2.out(`Library ${manifest.libraryId}, published ${manifest.publishedAt}.`);
|
|
2724
|
-
const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.
|
|
4101
|
+
const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.path ?? "not written", a.contentHash]);
|
|
2725
4102
|
const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
|
|
2726
4103
|
for (const row of rows) {
|
|
2727
4104
|
io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
|
|
2728
4105
|
}
|
|
4106
|
+
for (const o of manifest.outputs ?? []) {
|
|
4107
|
+
const written = existsSync8(join7(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
|
|
4108
|
+
io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
|
|
4109
|
+
}
|
|
2729
4110
|
return 0;
|
|
2730
4111
|
}
|
|
2731
4112
|
var SHOW_USAGE = 'spec-layer show takes "foundation" or "component NAME".';
|
|
@@ -2775,20 +4156,99 @@ Available: ${available || "none"}.`);
|
|
|
2775
4156
|
` : entry2.ai);
|
|
2776
4157
|
return 0;
|
|
2777
4158
|
}
|
|
4159
|
+
function runTools(flags, io2) {
|
|
4160
|
+
if (flags.json) io2.write(toolsJson(cliVersion()));
|
|
4161
|
+
else io2.out(toolsText());
|
|
4162
|
+
return 0;
|
|
4163
|
+
}
|
|
4164
|
+
function collectSkillInput(cwd, flags, io2) {
|
|
4165
|
+
let config = null;
|
|
4166
|
+
try {
|
|
4167
|
+
config = readConfig(cwd);
|
|
4168
|
+
} catch (err) {
|
|
4169
|
+
io2.err(errorText(err));
|
|
4170
|
+
return null;
|
|
4171
|
+
}
|
|
4172
|
+
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
4173
|
+
const profile = detectRepo(cwd);
|
|
4174
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4175
|
+
if (fromFlags === null) return null;
|
|
4176
|
+
const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
|
|
4177
|
+
const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
|
|
4178
|
+
return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
|
|
4179
|
+
}
|
|
4180
|
+
function skillHosts(flags, input, io2) {
|
|
4181
|
+
const named = flags.agent ?? [];
|
|
4182
|
+
if (named.length > 0) {
|
|
4183
|
+
const hosts = [];
|
|
4184
|
+
for (const value of named) {
|
|
4185
|
+
if (!isAgentHost(value)) {
|
|
4186
|
+
io2.err(`--agent takes ${AGENT_HOSTS.join(", ")}, not "${value}".`);
|
|
4187
|
+
return null;
|
|
4188
|
+
}
|
|
4189
|
+
if (!hosts.includes(value)) hosts.push(value);
|
|
4190
|
+
}
|
|
4191
|
+
return hosts;
|
|
4192
|
+
}
|
|
4193
|
+
return input.profile.agents.length > 0 ? input.profile.agents : ["agents-md"];
|
|
4194
|
+
}
|
|
4195
|
+
function runSkill(cwd, flags, io2) {
|
|
4196
|
+
const input = collectSkillInput(cwd, flags, io2);
|
|
4197
|
+
if (!input) return 1;
|
|
4198
|
+
const hosts = skillHosts(flags, input, io2);
|
|
4199
|
+
if (!hosts) return 1;
|
|
4200
|
+
if (flags.json) {
|
|
4201
|
+
io2.write(`${JSON.stringify({
|
|
4202
|
+
cli_version: input.version,
|
|
4203
|
+
detected: input.profile,
|
|
4204
|
+
platforms: input.platforms,
|
|
4205
|
+
platform_source: input.platformSource,
|
|
4206
|
+
pull: input.pull,
|
|
4207
|
+
install_targets: hosts.map((h) => installTarget(h))
|
|
4208
|
+
}, null, 2)}
|
|
4209
|
+
`);
|
|
4210
|
+
return 0;
|
|
4211
|
+
}
|
|
4212
|
+
const guide = buildSkillGuide(input);
|
|
4213
|
+
if (!flags.install) {
|
|
4214
|
+
io2.write(guide);
|
|
4215
|
+
return 0;
|
|
4216
|
+
}
|
|
4217
|
+
const chosen = flags.agent && flags.agent.length > 0 ? "named with --agent" : input.profile.agents.length > 0 ? "detected in this repository" : "the default when no agent is detected";
|
|
4218
|
+
for (const host of hosts) {
|
|
4219
|
+
let outcome;
|
|
4220
|
+
try {
|
|
4221
|
+
outcome = installSkill(cwd, host, guide);
|
|
4222
|
+
} catch (err) {
|
|
4223
|
+
io2.err(`Could not write ${installTarget(host).path}: ${errorText(err)}`);
|
|
4224
|
+
return 1;
|
|
4225
|
+
}
|
|
4226
|
+
const verb = outcome.result === "created" ? "Wrote" : outcome.result === "updated" ? "Updated" : "Unchanged:";
|
|
4227
|
+
io2.out(`${verb} ${outcome.path} (${host}, ${chosen}).`);
|
|
4228
|
+
}
|
|
4229
|
+
if (!input.pull) io2.out(`No local pull yet, so the guide lists no components. Run spec-layer pull, then spec-layer skill --install again.`);
|
|
4230
|
+
if (input.platformSource === "none") io2.out(`No target platform detected. Pass --platform ${PLATFORMS.join("|")} to write platform-specific token advice.`);
|
|
4231
|
+
return 0;
|
|
4232
|
+
}
|
|
2778
4233
|
|
|
2779
4234
|
// src/cli.ts
|
|
2780
4235
|
var USAGE = `spec-layer <command>
|
|
2781
4236
|
|
|
2782
4237
|
Commands:
|
|
2783
|
-
setup --id lib_... --key sl_... [--out DIR] [selection]
|
|
4238
|
+
setup --id lib_... --key sl_... [--out DIR] [selection] [--platform P]...
|
|
2784
4239
|
store the key, then pull
|
|
2785
|
-
init --id lib_... [--out DIR] [selection]
|
|
2786
|
-
|
|
4240
|
+
init --id lib_... [--out DIR] [selection] [--platform P]...
|
|
4241
|
+
write speclayer.json
|
|
4242
|
+
pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
|
|
2787
4243
|
fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
|
|
2788
4244
|
status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
|
|
2789
4245
|
list list every artifact in the last pull
|
|
2790
4246
|
show foundation | component NAME [--canonical]
|
|
2791
4247
|
print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
|
|
4248
|
+
tools [--json] list every command with what it reaches and writes
|
|
4249
|
+
skill [--install] [--agent HOST]... [--platform P]... [--json]
|
|
4250
|
+
print a guide for a coding agent, adapted to this repo and the last pull;
|
|
4251
|
+
--install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
|
|
2792
4252
|
|
|
2793
4253
|
Selection (setup, pull and init; flags replace the include block in speclayer.json):
|
|
2794
4254
|
--only foundation | components write just the foundation, or just components
|
|
@@ -2796,6 +4256,7 @@ Selection (setup, pull and init; flags replace the include block in speclayer.js
|
|
|
2796
4256
|
|
|
2797
4257
|
Options:
|
|
2798
4258
|
--api URL override the API origin (default https://api.spec-layer.com)
|
|
4259
|
+
--platform web|ios|android|flutter the target this repo builds for (repeatable); applies to setup, init, pull, and skill; setup and init store it, pull uses it for the run
|
|
2799
4260
|
The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
|
|
2800
4261
|
var io = {
|
|
2801
4262
|
out: (l) => console.log(l),
|
|
@@ -2817,7 +4278,11 @@ async function main() {
|
|
|
2817
4278
|
api: { type: "string" },
|
|
2818
4279
|
only: { type: "string" },
|
|
2819
4280
|
component: { type: "string", multiple: true },
|
|
2820
|
-
canonical: { type: "boolean" }
|
|
4281
|
+
canonical: { type: "boolean" },
|
|
4282
|
+
json: { type: "boolean" },
|
|
4283
|
+
install: { type: "boolean" },
|
|
4284
|
+
agent: { type: "string", multiple: true },
|
|
4285
|
+
platform: { type: "string", multiple: true }
|
|
2821
4286
|
}
|
|
2822
4287
|
}));
|
|
2823
4288
|
} catch {
|
|
@@ -2833,6 +4298,8 @@ async function main() {
|
|
|
2833
4298
|
if (command === "status") return await runStatus(cwd, values, process.env, io);
|
|
2834
4299
|
if (command === "list") return runList(cwd, values, io);
|
|
2835
4300
|
if (command === "show") return runShow(cwd, values, positionals.slice(1), io);
|
|
4301
|
+
if (command === "tools") return runTools(values, io);
|
|
4302
|
+
if (command === "skill") return runSkill(cwd, values, io);
|
|
2836
4303
|
io.err(USAGE);
|
|
2837
4304
|
return 1;
|
|
2838
4305
|
} catch (err) {
|