spec-layer 0.5.0 → 0.7.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 +138 -17
- package/dist/cli.js +1253 -319
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -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 join8, resolve as resolve5 } from "node:path";
|
|
563
564
|
|
|
564
565
|
// ../extractor/src/statesMatrix.ts
|
|
565
566
|
var STATE_ORDER = [
|
|
@@ -1333,7 +1334,15 @@ function dtcgSegments(name) {
|
|
|
1333
1334
|
}
|
|
1334
1335
|
return { segments, notes };
|
|
1335
1336
|
}
|
|
1336
|
-
|
|
1337
|
+
function trimDashes(s) {
|
|
1338
|
+
let start = 0;
|
|
1339
|
+
let end = s.length;
|
|
1340
|
+
while (start < end && s[start] === "-") start += 1;
|
|
1341
|
+
while (end > start && s[end - 1] === "-") end -= 1;
|
|
1342
|
+
return s.slice(start, end);
|
|
1343
|
+
}
|
|
1344
|
+
var dtcgSlug = (s) => trimDashes(s.toLowerCase().replace(/[^a-z0-9]+/g, "-")) || "unnamed";
|
|
1345
|
+
var slug = dtcgSlug;
|
|
1337
1346
|
var RESERVED_FILE_NAMES = [
|
|
1338
1347
|
"styles.typography.json",
|
|
1339
1348
|
"styles.effects.json",
|
|
@@ -1611,15 +1620,15 @@ function styleMember(p, property, scopes, path, name) {
|
|
|
1611
1620
|
});
|
|
1612
1621
|
return null;
|
|
1613
1622
|
}
|
|
1614
|
-
const
|
|
1615
|
-
if ("omit" in
|
|
1616
|
-
if (
|
|
1623
|
+
const converted2 = dtcgLiteral(property.resolved, scopes, p.options.values);
|
|
1624
|
+
if ("omit" in converted2) {
|
|
1625
|
+
if (converted2.omit === "unit_not_expressible") {
|
|
1617
1626
|
reportOnce(p, {
|
|
1618
1627
|
code: "unit_not_expressible",
|
|
1619
1628
|
severity: "info",
|
|
1620
1629
|
path,
|
|
1621
1630
|
message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
|
|
1622
|
-
details: { property: name, ...
|
|
1631
|
+
details: { property: name, ...converted2.details }
|
|
1623
1632
|
});
|
|
1624
1633
|
const d = property.resolved;
|
|
1625
1634
|
return { extension: { value: d.number, unit: d.unit } };
|
|
@@ -1629,11 +1638,11 @@ function styleMember(p, property, scopes, path, name) {
|
|
|
1629
1638
|
severity: "warning",
|
|
1630
1639
|
path,
|
|
1631
1640
|
message: `The ${name} property has a type DTCG cannot state and was omitted.`,
|
|
1632
|
-
details: { property: name, ...
|
|
1641
|
+
details: { property: name, ...converted2.details }
|
|
1633
1642
|
});
|
|
1634
1643
|
return null;
|
|
1635
1644
|
}
|
|
1636
|
-
return { value:
|
|
1645
|
+
return { value: converted2.$value };
|
|
1637
1646
|
}
|
|
1638
1647
|
var TYPOGRAPHY_MEMBERS = [
|
|
1639
1648
|
["font_family", "fontFamily", []],
|
|
@@ -1733,8 +1742,8 @@ function effectLeaf(p, style, path) {
|
|
|
1733
1742
|
}
|
|
1734
1743
|
const raw = effect[field];
|
|
1735
1744
|
if (raw === void 0) continue;
|
|
1736
|
-
const
|
|
1737
|
-
if (!("omit" in
|
|
1745
|
+
const converted2 = dtcgLiteral(raw, [], p.options.values);
|
|
1746
|
+
if (!("omit" in converted2)) shadow[name] = converted2.$value;
|
|
1738
1747
|
}
|
|
1739
1748
|
shadow.inset = effect.type === "inner_shadow";
|
|
1740
1749
|
shadows.push(shadow);
|
|
@@ -2000,7 +2009,7 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2000
2009
|
}
|
|
2001
2010
|
return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
|
|
2002
2011
|
}
|
|
2003
|
-
const
|
|
2012
|
+
const converted2 = projectedLiteral(p, token, value.value, (override) => {
|
|
2004
2013
|
reportOnce(p, {
|
|
2005
2014
|
code: "unit_override_conflicts_with_scope",
|
|
2006
2015
|
severity: "warning",
|
|
@@ -2009,18 +2018,18 @@ function tokenLeaf(p, token, collection, modeId) {
|
|
|
2009
2018
|
details: { id: token.id, override, scopes: [...token.scopes] }
|
|
2010
2019
|
});
|
|
2011
2020
|
});
|
|
2012
|
-
if ("omit" in
|
|
2021
|
+
if ("omit" in converted2) {
|
|
2013
2022
|
reportOnce(p, {
|
|
2014
|
-
code:
|
|
2023
|
+
code: converted2.omit,
|
|
2015
2024
|
severity: "warning",
|
|
2016
2025
|
path,
|
|
2017
2026
|
mode,
|
|
2018
|
-
message:
|
|
2019
|
-
details: { id: token.id, ...
|
|
2027
|
+
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.`,
|
|
2028
|
+
details: { id: token.id, ...converted2.details }
|
|
2020
2029
|
});
|
|
2021
2030
|
return null;
|
|
2022
2031
|
}
|
|
2023
|
-
return { $type:
|
|
2032
|
+
return { $type: converted2.$type, $value: converted2.$value, ...description };
|
|
2024
2033
|
}
|
|
2025
2034
|
function dtcgExportFiles(out) {
|
|
2026
2035
|
const text = (v) => `${JSON.stringify(v, null, 2)}
|
|
@@ -2033,6 +2042,509 @@ function dtcgExportFiles(out) {
|
|
|
2033
2042
|
return files;
|
|
2034
2043
|
}
|
|
2035
2044
|
|
|
2045
|
+
// ../extractor/src/v5/outputs/naming.ts
|
|
2046
|
+
var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
|
|
2047
|
+
function splitWords(segment) {
|
|
2048
|
+
return segment.replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
|
|
2049
|
+
}
|
|
2050
|
+
function pathWords(path) {
|
|
2051
|
+
return path.split(".").flatMap((seg) => {
|
|
2052
|
+
const words = splitWords(seg);
|
|
2053
|
+
return words.length > 0 ? words : ["_"];
|
|
2054
|
+
});
|
|
2055
|
+
}
|
|
2056
|
+
function joinWords(words, nameCase) {
|
|
2057
|
+
const lower = words.map((w) => w.toLowerCase());
|
|
2058
|
+
const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
|
|
2059
|
+
switch (nameCase) {
|
|
2060
|
+
case "kebab":
|
|
2061
|
+
return lower.join("-");
|
|
2062
|
+
case "snake":
|
|
2063
|
+
return lower.join("_");
|
|
2064
|
+
case "constant":
|
|
2065
|
+
return lower.join("_").toUpperCase();
|
|
2066
|
+
case "camel":
|
|
2067
|
+
return lower.map((w, i) => i === 0 ? w : cap(w)).join("");
|
|
2068
|
+
case "pascal":
|
|
2069
|
+
return lower.map(cap).join("");
|
|
2070
|
+
default: {
|
|
2071
|
+
const exhaustive = nameCase;
|
|
2072
|
+
return exhaustive;
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
2076
|
+
function deriveName(path, nameCase) {
|
|
2077
|
+
return joinWords(pathWords(path), nameCase);
|
|
2078
|
+
}
|
|
2079
|
+
function sortReport(entries) {
|
|
2080
|
+
return [...entries].sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
|
|
2081
|
+
}
|
|
2082
|
+
function resolveNames(paths, meta, rules) {
|
|
2083
|
+
const report2 = [];
|
|
2084
|
+
const candidates = /* @__PURE__ */ new Map();
|
|
2085
|
+
for (const path of [...paths].sort(compareCodeUnits)) {
|
|
2086
|
+
let name = null;
|
|
2087
|
+
let source = "derived";
|
|
2088
|
+
const declared = rules.codeSyntaxKey ? meta[path]?.code_syntax?.[rules.codeSyntaxKey] : void 0;
|
|
2089
|
+
if (declared !== void 0) {
|
|
2090
|
+
name = rules.acceptDeclared(declared);
|
|
2091
|
+
if (name !== null) {
|
|
2092
|
+
source = "code_syntax";
|
|
2093
|
+
} else {
|
|
2094
|
+
report2.push({
|
|
2095
|
+
code: "code_syntax_not_usable",
|
|
2096
|
+
severity: "info",
|
|
2097
|
+
path,
|
|
2098
|
+
message: `The declared ${rules.codeSyntaxKey} identifier "${declared}" is not a name this format can use; the name was derived instead.`,
|
|
2099
|
+
details: { declared, platform: rules.codeSyntaxKey ?? "" }
|
|
2100
|
+
});
|
|
2101
|
+
}
|
|
2102
|
+
}
|
|
2103
|
+
if (name === null) name = rules.affix(deriveName(path, rules.nameCase));
|
|
2104
|
+
const list = candidates.get(name) ?? [];
|
|
2105
|
+
list.push({ path, source });
|
|
2106
|
+
candidates.set(name, list);
|
|
2107
|
+
}
|
|
2108
|
+
const names = /* @__PURE__ */ new Map();
|
|
2109
|
+
const map = {};
|
|
2110
|
+
for (const [name, list] of candidates) {
|
|
2111
|
+
if (list.length > 1) {
|
|
2112
|
+
const collided = list.map((c) => c.path).sort(compareCodeUnits);
|
|
2113
|
+
for (const c of list) {
|
|
2114
|
+
report2.push({
|
|
2115
|
+
code: "name_collision",
|
|
2116
|
+
severity: "error",
|
|
2117
|
+
path: c.path,
|
|
2118
|
+
message: `${list.length} tokens would share the name ${name}; all were omitted.`,
|
|
2119
|
+
details: { name, paths: collided }
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
continue;
|
|
2123
|
+
}
|
|
2124
|
+
names.set(list[0].path, name);
|
|
2125
|
+
map[list[0].path] = { name, source: list[0].source };
|
|
2126
|
+
}
|
|
2127
|
+
const sortedMap = Object.fromEntries(Object.entries(map).sort(([a], [b]) => compareCodeUnits(a, b)));
|
|
2128
|
+
return { names, map: sortedMap, report: sortReport(report2) };
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
// ../extractor/src/v5/outputs/css.ts
|
|
2132
|
+
var CSS_INDEX_FILE = "index.css";
|
|
2133
|
+
var CSS_DEFAULTS = {
|
|
2134
|
+
case: "kebab",
|
|
2135
|
+
root: ":root",
|
|
2136
|
+
modeSelector: '[data-theme="{mode}"]'
|
|
2137
|
+
};
|
|
2138
|
+
var CSS_HEADER_PREFIX = "/* Generated by spec-layer";
|
|
2139
|
+
var EXT = "com.spec-layer";
|
|
2140
|
+
var CUSTOM_PROPERTY = /^--[A-Za-z0-9_-]+$/;
|
|
2141
|
+
var BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
2142
|
+
function acceptCssDeclared(declared) {
|
|
2143
|
+
if (CUSTOM_PROPERTY.test(declared)) return declared;
|
|
2144
|
+
if (BARE_IDENT.test(declared)) return `--${declared}`;
|
|
2145
|
+
return null;
|
|
2146
|
+
}
|
|
2147
|
+
var asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
|
|
2148
|
+
function collectLeaves(tree, prefix, out) {
|
|
2149
|
+
const node = asRecord(tree);
|
|
2150
|
+
if (!node) return;
|
|
2151
|
+
if ("$value" in node) {
|
|
2152
|
+
const ours = asRecord(asRecord(node.$extensions)?.[EXT]);
|
|
2153
|
+
out.push({
|
|
2154
|
+
path: prefix.join("."),
|
|
2155
|
+
type: typeof node.$type === "string" ? node.$type : "",
|
|
2156
|
+
value: node.$value,
|
|
2157
|
+
ext: ours
|
|
2158
|
+
});
|
|
2159
|
+
return;
|
|
2160
|
+
}
|
|
2161
|
+
for (const key of Object.keys(node)) {
|
|
2162
|
+
if (key.startsWith("$")) continue;
|
|
2163
|
+
collectLeaves(node[key], [...prefix, key], out);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
var unpointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
2167
|
+
var refFile = (src) => {
|
|
2168
|
+
const r = asRecord(src);
|
|
2169
|
+
return r && typeof r.$ref === "string" ? r.$ref : null;
|
|
2170
|
+
};
|
|
2171
|
+
function sourcesOf(resolver) {
|
|
2172
|
+
const out = [];
|
|
2173
|
+
for (const { $ref } of resolver.resolutionOrder) {
|
|
2174
|
+
const set = /^#\/sets\/(.+)$/.exec($ref);
|
|
2175
|
+
const mod = /^#\/modifiers\/(.+)$/.exec($ref);
|
|
2176
|
+
if (set) {
|
|
2177
|
+
const label = unpointer(set[1]);
|
|
2178
|
+
for (const src of resolver.sets[label]?.sources ?? []) {
|
|
2179
|
+
const file = refFile(src);
|
|
2180
|
+
if (file) out.push({ collection: label, mode: null, file, isDefault: true });
|
|
2181
|
+
}
|
|
2182
|
+
} else if (mod) {
|
|
2183
|
+
const label = unpointer(mod[1]);
|
|
2184
|
+
const m = resolver.modifiers[label];
|
|
2185
|
+
if (!m) continue;
|
|
2186
|
+
for (const [context, srcs] of Object.entries(m.contexts)) {
|
|
2187
|
+
for (const src of srcs) {
|
|
2188
|
+
const file = refFile(src);
|
|
2189
|
+
if (file) out.push({ collection: label, mode: context, file, isDefault: context === m.default });
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
return out;
|
|
2195
|
+
}
|
|
2196
|
+
var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
|
|
2197
|
+
var collectionSlug = (file) => file.split(".")[0];
|
|
2198
|
+
function cssFileNames(sources) {
|
|
2199
|
+
const taken = /* @__PURE__ */ new Set([CSS_INDEX_FILE]);
|
|
2200
|
+
const out = /* @__PURE__ */ new Map();
|
|
2201
|
+
for (const s of sources) {
|
|
2202
|
+
if (out.has(s.file)) continue;
|
|
2203
|
+
const base = s.mode === null ? dtcgSlug(s.collection) : `${dtcgSlug(s.collection)}.${modeSlug(s.file)}`;
|
|
2204
|
+
let candidate = `${base}.css`;
|
|
2205
|
+
let n = 1;
|
|
2206
|
+
while (taken.has(candidate)) {
|
|
2207
|
+
n += 1;
|
|
2208
|
+
candidate = `${base}-${n}.css`;
|
|
2209
|
+
}
|
|
2210
|
+
taken.add(candidate);
|
|
2211
|
+
out.set(s.file, candidate);
|
|
2212
|
+
}
|
|
2213
|
+
return out;
|
|
2214
|
+
}
|
|
2215
|
+
var REF = /^\{(.+)\}$/;
|
|
2216
|
+
function hexChannels(hex) {
|
|
2217
|
+
const at = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
2218
|
+
return [at(1), at(3), at(5)];
|
|
2219
|
+
}
|
|
2220
|
+
var quoteFamily = (f) => `"${f.replace(/["\\]/g, "\\$&")}"`;
|
|
2221
|
+
function report(ctx, entry2) {
|
|
2222
|
+
ctx.report.push({ ...entry2, path: ctx.path, ...ctx.mode !== void 0 ? { mode: ctx.mode } : {} });
|
|
2223
|
+
}
|
|
2224
|
+
function cssValue(ctx, type, value, property) {
|
|
2225
|
+
const member = property !== void 0 ? { property } : {};
|
|
2226
|
+
if (typeof value === "string") {
|
|
2227
|
+
const ref = REF.exec(value);
|
|
2228
|
+
if (ref) {
|
|
2229
|
+
const name = ctx.alive.has(ref[1]) ? ctx.names.get(ref[1]) : void 0;
|
|
2230
|
+
if (name !== void 0) return `var(${name})`;
|
|
2231
|
+
report(ctx, {
|
|
2232
|
+
code: "reference_target_omitted",
|
|
2233
|
+
severity: "warning",
|
|
2234
|
+
message: `References ${ref[1]}, which this output does not emit; the value was omitted.`,
|
|
2235
|
+
details: { target: ref[1], ...member }
|
|
2236
|
+
});
|
|
2237
|
+
return null;
|
|
2238
|
+
}
|
|
2239
|
+
}
|
|
2240
|
+
switch (type) {
|
|
2241
|
+
case "color": {
|
|
2242
|
+
if (typeof value === "string") return value;
|
|
2243
|
+
const c = asRecord(value);
|
|
2244
|
+
if (c && typeof c.hex === "string" && typeof c.alpha === "number") {
|
|
2245
|
+
if (c.alpha === 1) return c.hex;
|
|
2246
|
+
const [r, g, b] = hexChannels(c.hex);
|
|
2247
|
+
return `rgb(${r} ${g} ${b} / ${c.alpha})`;
|
|
2248
|
+
}
|
|
2249
|
+
break;
|
|
2250
|
+
}
|
|
2251
|
+
case "dimension":
|
|
2252
|
+
case "duration": {
|
|
2253
|
+
if (typeof value === "string") return value;
|
|
2254
|
+
const d = asRecord(value);
|
|
2255
|
+
if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
|
|
2256
|
+
break;
|
|
2257
|
+
}
|
|
2258
|
+
case "number":
|
|
2259
|
+
case "fontWeight":
|
|
2260
|
+
if (typeof value === "number") return String(value);
|
|
2261
|
+
break;
|
|
2262
|
+
case "cubicBezier":
|
|
2263
|
+
if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
|
|
2264
|
+
return `cubic-bezier(${value.join(", ")})`;
|
|
2265
|
+
}
|
|
2266
|
+
break;
|
|
2267
|
+
case "fontFamily":
|
|
2268
|
+
if (typeof value === "string") return quoteFamily(value);
|
|
2269
|
+
if (Array.isArray(value) && value.every((f) => typeof f === "string")) {
|
|
2270
|
+
return value.map(quoteFamily).join(", ");
|
|
2271
|
+
}
|
|
2272
|
+
break;
|
|
2273
|
+
default:
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
report(ctx, {
|
|
2277
|
+
code: "not_expressible",
|
|
2278
|
+
severity: "warning",
|
|
2279
|
+
message: `CSS has no form for this ${type || "untyped"} value; it was omitted.`,
|
|
2280
|
+
details: { type, value, ...member }
|
|
2281
|
+
});
|
|
2282
|
+
return null;
|
|
2283
|
+
}
|
|
2284
|
+
var TEXT_TRANSFORM = { original: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" };
|
|
2285
|
+
var TEXT_DECORATION = { none: "none", underline: "underline", strikethrough: "line-through" };
|
|
2286
|
+
var TYPOGRAPHY_MEMBERS2 = [
|
|
2287
|
+
["fontFamily", "fontFamily"],
|
|
2288
|
+
["fontSize", "dimension"],
|
|
2289
|
+
["fontWeight", "fontWeight"],
|
|
2290
|
+
["lineHeight", "number"],
|
|
2291
|
+
["letterSpacing", "dimension"]
|
|
2292
|
+
];
|
|
2293
|
+
var TEXT_MEMBERS = [
|
|
2294
|
+
["textTransform", "textCase", TEXT_TRANSFORM],
|
|
2295
|
+
["textDecoration", "textDecoration", TEXT_DECORATION]
|
|
2296
|
+
];
|
|
2297
|
+
function typographyMemberKeys(value, ext) {
|
|
2298
|
+
const keys = [];
|
|
2299
|
+
for (const [key] of TYPOGRAPHY_MEMBERS2) {
|
|
2300
|
+
if (key in value) keys.push(key);
|
|
2301
|
+
}
|
|
2302
|
+
for (const key of ["lineHeight", "letterSpacing"]) {
|
|
2303
|
+
if (key in value) continue;
|
|
2304
|
+
const d = asRecord(ext[key]);
|
|
2305
|
+
if (d && typeof d.value === "number" && typeof d.unit === "string") keys.push(key);
|
|
2306
|
+
}
|
|
2307
|
+
for (const [key, extKey] of TEXT_MEMBERS) {
|
|
2308
|
+
if (typeof ext[extKey] === "string") keys.push(key);
|
|
2309
|
+
}
|
|
2310
|
+
return keys;
|
|
2311
|
+
}
|
|
2312
|
+
function converted(ctx, property, from, to) {
|
|
2313
|
+
report(ctx, {
|
|
2314
|
+
code: "value_converted",
|
|
2315
|
+
severity: "info",
|
|
2316
|
+
message: `${property} was restated from ${String(from.value)}${String(from.unit)} as ${to}.`,
|
|
2317
|
+
details: { property, from, to }
|
|
2318
|
+
});
|
|
2319
|
+
}
|
|
2320
|
+
function extensionDimension(ctx, ext, key, name, percentTo) {
|
|
2321
|
+
const d = asRecord(ext[key]);
|
|
2322
|
+
if (!d || typeof d.value !== "number" || typeof d.unit !== "string") return null;
|
|
2323
|
+
if (d.unit === "%") {
|
|
2324
|
+
const to = percentTo(canonicalNumber(d.value / 100));
|
|
2325
|
+
converted(ctx, key, d, to);
|
|
2326
|
+
return `${name}: ${to};`;
|
|
2327
|
+
}
|
|
2328
|
+
return `${name}: ${d.value}${d.unit};`;
|
|
2329
|
+
}
|
|
2330
|
+
function typographyDecls(ctx, leaf, names) {
|
|
2331
|
+
const decls = [];
|
|
2332
|
+
const declaredPaths = [];
|
|
2333
|
+
const value = asRecord(leaf.value) ?? {};
|
|
2334
|
+
const ext = leaf.ext ?? {};
|
|
2335
|
+
const nameFor = (key) => names.get(`${leaf.path}.${key}`);
|
|
2336
|
+
for (const [key, type] of TYPOGRAPHY_MEMBERS2) {
|
|
2337
|
+
if (!(key in value)) continue;
|
|
2338
|
+
const name = nameFor(key);
|
|
2339
|
+
if (name === void 0) continue;
|
|
2340
|
+
const css = cssValue(ctx, type, value[key], key);
|
|
2341
|
+
if (css !== null) {
|
|
2342
|
+
decls.push(`${name}: ${css};`);
|
|
2343
|
+
declaredPaths.push(`${leaf.path}.${key}`);
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
if (!("lineHeight" in value)) {
|
|
2347
|
+
const name = nameFor("lineHeight");
|
|
2348
|
+
if (name !== void 0) {
|
|
2349
|
+
const d = extensionDimension(ctx, ext, "lineHeight", name, (n) => String(n));
|
|
2350
|
+
if (d) {
|
|
2351
|
+
decls.push(d);
|
|
2352
|
+
declaredPaths.push(`${leaf.path}.lineHeight`);
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
if (!("letterSpacing" in value)) {
|
|
2357
|
+
const name = nameFor("letterSpacing");
|
|
2358
|
+
if (name !== void 0) {
|
|
2359
|
+
const d = extensionDimension(ctx, ext, "letterSpacing", name, (n) => `${n}em`);
|
|
2360
|
+
if (d) {
|
|
2361
|
+
decls.push(d);
|
|
2362
|
+
declaredPaths.push(`${leaf.path}.letterSpacing`);
|
|
2363
|
+
}
|
|
2364
|
+
}
|
|
2365
|
+
}
|
|
2366
|
+
for (const [key, extKey, table] of TEXT_MEMBERS) {
|
|
2367
|
+
const raw = ext[extKey];
|
|
2368
|
+
if (typeof raw !== "string") continue;
|
|
2369
|
+
const name = nameFor(key);
|
|
2370
|
+
if (name === void 0) continue;
|
|
2371
|
+
const css = table[raw];
|
|
2372
|
+
if (css !== void 0) {
|
|
2373
|
+
decls.push(`${name}: ${css};`);
|
|
2374
|
+
declaredPaths.push(`${leaf.path}.${key}`);
|
|
2375
|
+
} else {
|
|
2376
|
+
report(ctx, {
|
|
2377
|
+
code: "not_expressible",
|
|
2378
|
+
severity: "info",
|
|
2379
|
+
message: `CSS text properties have no form for ${extKey} "${raw}"; it was omitted.`,
|
|
2380
|
+
details: { property: extKey, value: raw }
|
|
2381
|
+
});
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
return { decls, declaredPaths };
|
|
2385
|
+
}
|
|
2386
|
+
var SHADOW_MEMBERS = [
|
|
2387
|
+
["offsetX", "dimension"],
|
|
2388
|
+
["offsetY", "dimension"],
|
|
2389
|
+
["blur", "dimension"],
|
|
2390
|
+
["spread", "dimension"],
|
|
2391
|
+
["color", "color"]
|
|
2392
|
+
];
|
|
2393
|
+
function shadowDecl(ctx, leaf, name) {
|
|
2394
|
+
const layers = Array.isArray(leaf.value) ? leaf.value : [];
|
|
2395
|
+
const omit = (reason, message) => {
|
|
2396
|
+
report(ctx, { code: "not_expressible", severity: "warning", message, details: { reason } });
|
|
2397
|
+
return null;
|
|
2398
|
+
};
|
|
2399
|
+
if (layers.length === 0) return omit("no_visible_shadow", "The style has no visible shadow, so no box-shadow was written.");
|
|
2400
|
+
const parts = [];
|
|
2401
|
+
for (const layer of layers) {
|
|
2402
|
+
const l = asRecord(layer);
|
|
2403
|
+
if (!l) return omit("layer_not_an_object", "A shadow layer is not an object; the style was omitted.");
|
|
2404
|
+
const members = [];
|
|
2405
|
+
for (const [key, type] of SHADOW_MEMBERS) {
|
|
2406
|
+
if (!(key in l)) return omit(`missing_${key}`, `A shadow layer has no ${key}, which box-shadow needs; the style was omitted.`);
|
|
2407
|
+
const css = cssValue(ctx, type, l[key], key);
|
|
2408
|
+
if (css === null) return null;
|
|
2409
|
+
members.push(css);
|
|
2410
|
+
}
|
|
2411
|
+
parts.push(`${l.inset === true ? "inset " : ""}${members.join(" ")}`);
|
|
2412
|
+
}
|
|
2413
|
+
return `${name}: ${parts.join(", ")};`;
|
|
2414
|
+
}
|
|
2415
|
+
var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
|
|
2416
|
+
function headerText(header, nameCase) {
|
|
2417
|
+
return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
|
|
2418
|
+
Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
|
|
2419
|
+
}
|
|
2420
|
+
function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
2421
|
+
const entries = [];
|
|
2422
|
+
const declared = /* @__PURE__ */ new Set();
|
|
2423
|
+
const firstFile = /* @__PURE__ */ new Map();
|
|
2424
|
+
const blocks = /* @__PURE__ */ new Map();
|
|
2425
|
+
for (const s of sources) {
|
|
2426
|
+
const perCollection = modes?.[s.collection];
|
|
2427
|
+
const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
|
|
2428
|
+
const decls = [];
|
|
2429
|
+
const declaredHere = [];
|
|
2430
|
+
for (const leaf of leavesByFile.get(s.file) ?? []) {
|
|
2431
|
+
const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
|
|
2432
|
+
if (leaf.type === "typography") {
|
|
2433
|
+
const t = typographyDecls(ctx, leaf, names);
|
|
2434
|
+
decls.push(...t.decls);
|
|
2435
|
+
for (const p of t.declaredPaths) {
|
|
2436
|
+
declared.add(p);
|
|
2437
|
+
declaredHere.push(p);
|
|
2438
|
+
}
|
|
2439
|
+
} else {
|
|
2440
|
+
const name = names.get(leaf.path);
|
|
2441
|
+
if (name === void 0) continue;
|
|
2442
|
+
if (leaf.type === "shadow") {
|
|
2443
|
+
const d = shadowDecl(ctx, leaf, name);
|
|
2444
|
+
if (d !== null) {
|
|
2445
|
+
decls.push(d);
|
|
2446
|
+
declared.add(leaf.path);
|
|
2447
|
+
declaredHere.push(leaf.path);
|
|
2448
|
+
}
|
|
2449
|
+
} else {
|
|
2450
|
+
const v = cssValue(ctx, leaf.type, leaf.value);
|
|
2451
|
+
if (v !== null) {
|
|
2452
|
+
decls.push(`${name}: ${v};`);
|
|
2453
|
+
declared.add(leaf.path);
|
|
2454
|
+
declaredHere.push(leaf.path);
|
|
2455
|
+
}
|
|
2456
|
+
}
|
|
2457
|
+
}
|
|
2458
|
+
}
|
|
2459
|
+
if (decls.length === 0) continue;
|
|
2460
|
+
for (const p of declaredHere) if (!firstFile.has(p)) firstFile.set(p, s.file);
|
|
2461
|
+
const comment = `/* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`;
|
|
2462
|
+
const existing = blocks.get(s.file);
|
|
2463
|
+
if (existing) existing.decls.push(...decls);
|
|
2464
|
+
else blocks.set(s.file, { selector, comment, decls });
|
|
2465
|
+
}
|
|
2466
|
+
return { blocks, entries, declared, firstFile };
|
|
2467
|
+
}
|
|
2468
|
+
function cssOutput(exp, header, options = {}) {
|
|
2469
|
+
const nameCase = options.case ?? CSS_DEFAULTS.case;
|
|
2470
|
+
const root = options.root ?? CSS_DEFAULTS.root;
|
|
2471
|
+
const template = options.modeSelector ?? CSS_DEFAULTS.modeSelector;
|
|
2472
|
+
const sources = sourcesOf(exp.resolver);
|
|
2473
|
+
const leavesByFile = /* @__PURE__ */ new Map();
|
|
2474
|
+
for (const s of sources) {
|
|
2475
|
+
if (!leavesByFile.has(s.file)) {
|
|
2476
|
+
const out = [];
|
|
2477
|
+
collectLeaves(exp.files[s.file] ?? {}, [], out);
|
|
2478
|
+
leavesByFile.set(s.file, out);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
const candidatePaths = /* @__PURE__ */ new Set();
|
|
2482
|
+
for (const leaves of leavesByFile.values()) {
|
|
2483
|
+
for (const leaf of leaves) {
|
|
2484
|
+
if (leaf.type === "typography") {
|
|
2485
|
+
const value = asRecord(leaf.value) ?? {};
|
|
2486
|
+
const ext = leaf.ext ?? {};
|
|
2487
|
+
for (const key of typographyMemberKeys(value, ext)) candidatePaths.add(`${leaf.path}.${key}`);
|
|
2488
|
+
} else {
|
|
2489
|
+
candidatePaths.add(leaf.path);
|
|
2490
|
+
}
|
|
2491
|
+
}
|
|
2492
|
+
}
|
|
2493
|
+
const resolved2 = resolveNames([...candidatePaths], exp.meta, {
|
|
2494
|
+
codeSyntaxKey: "WEB",
|
|
2495
|
+
acceptDeclared: acceptCssDeclared,
|
|
2496
|
+
affix: (body) => `--${body}`,
|
|
2497
|
+
nameCase
|
|
2498
|
+
});
|
|
2499
|
+
const names = resolved2.names;
|
|
2500
|
+
const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
|
|
2501
|
+
let alive = new Set(names.keys());
|
|
2502
|
+
let pass = emit(alive);
|
|
2503
|
+
for (; ; ) {
|
|
2504
|
+
if (pass.declared.size === alive.size) break;
|
|
2505
|
+
alive = pass.declared;
|
|
2506
|
+
pass = emit(alive);
|
|
2507
|
+
}
|
|
2508
|
+
const fileNames = cssFileNames(sources);
|
|
2509
|
+
const map = {};
|
|
2510
|
+
for (const [path, entry2] of Object.entries(resolved2.map)) {
|
|
2511
|
+
const from = pass.firstFile.get(path);
|
|
2512
|
+
if (alive.has(path) && from !== void 0) map[path] = { ...entry2, file: fileNames.get(from) };
|
|
2513
|
+
}
|
|
2514
|
+
const entries = [...resolved2.report, ...pass.entries];
|
|
2515
|
+
const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
|
|
2516
|
+
if (shared.length > 1) {
|
|
2517
|
+
for (const collection of shared) {
|
|
2518
|
+
entries.push({
|
|
2519
|
+
code: "mode_selector_shared",
|
|
2520
|
+
severity: "warning",
|
|
2521
|
+
path: collection,
|
|
2522
|
+
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.`,
|
|
2523
|
+
details: { modifiers: shared, selector: template }
|
|
2524
|
+
});
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
const head = headerText(header, nameCase);
|
|
2528
|
+
const files = {};
|
|
2529
|
+
const imports = [];
|
|
2530
|
+
for (const [source, block] of pass.blocks) {
|
|
2531
|
+
const name = fileNames.get(source);
|
|
2532
|
+
files[name] = `${head}
|
|
2533
|
+
|
|
2534
|
+
${block.selector} {
|
|
2535
|
+
${block.comment}
|
|
2536
|
+
${block.decls.map((d) => ` ${d}`).join("\n")}
|
|
2537
|
+
}
|
|
2538
|
+
`;
|
|
2539
|
+
imports.push(block.comment, `@import "./${name}";`);
|
|
2540
|
+
}
|
|
2541
|
+
if (imports.length > 0) files[CSS_INDEX_FILE] = `${head}
|
|
2542
|
+
|
|
2543
|
+
${imports.join("\n")}
|
|
2544
|
+
`;
|
|
2545
|
+
return { files, map, report: sortReport(entries) };
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2036
2548
|
// ../extractor/src/v5/componentContext.ts
|
|
2037
2549
|
var import_js_sha2563 = __toESM(require_sha256(), 1);
|
|
2038
2550
|
|
|
@@ -2101,6 +2613,9 @@ function parseLibraryBundle(input) {
|
|
|
2101
2613
|
};
|
|
2102
2614
|
}
|
|
2103
2615
|
|
|
2616
|
+
// ../extractor/src/libraryBundleHash.ts
|
|
2617
|
+
var import_js_sha2564 = __toESM(require_sha256(), 1);
|
|
2618
|
+
|
|
2104
2619
|
// src/bundle.ts
|
|
2105
2620
|
function parseBundle(raw) {
|
|
2106
2621
|
try {
|
|
@@ -2119,57 +2634,377 @@ function parseBundle(raw) {
|
|
|
2119
2634
|
}
|
|
2120
2635
|
}
|
|
2121
2636
|
}
|
|
2122
|
-
|
|
2123
|
-
// src/config.ts
|
|
2124
|
-
import { readFileSync as
|
|
2125
|
-
import { join as
|
|
2126
|
-
|
|
2127
|
-
// src/credentials.ts
|
|
2128
|
-
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
2129
|
-
import { join } from "node:path";
|
|
2130
|
-
var CREDENTIALS_NAME = "speclayer.local.json";
|
|
2131
|
-
var unreadable = () => new Error(
|
|
2132
|
-
`${CREDENTIALS_NAME} cannot be read. Delete it, then run the setup command from the plugin's Library screen.`
|
|
2133
|
-
);
|
|
2134
|
-
function readCredentials(cwd) {
|
|
2135
|
-
const path = join(cwd, CREDENTIALS_NAME);
|
|
2136
|
-
if (!existsSync(path)) return null;
|
|
2137
|
-
let parsed;
|
|
2637
|
+
|
|
2638
|
+
// src/config.ts
|
|
2639
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
|
|
2640
|
+
import { join as join4 } from "node:path";
|
|
2641
|
+
|
|
2642
|
+
// src/credentials.ts
|
|
2643
|
+
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
2644
|
+
import { join } from "node:path";
|
|
2645
|
+
var CREDENTIALS_NAME = "speclayer.local.json";
|
|
2646
|
+
var unreadable = () => new Error(
|
|
2647
|
+
`${CREDENTIALS_NAME} cannot be read. Delete it, then run the setup command from the plugin's Library screen.`
|
|
2648
|
+
);
|
|
2649
|
+
function readCredentials(cwd) {
|
|
2650
|
+
const path = join(cwd, CREDENTIALS_NAME);
|
|
2651
|
+
if (!existsSync(path)) return null;
|
|
2652
|
+
let parsed;
|
|
2653
|
+
try {
|
|
2654
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
2655
|
+
} catch {
|
|
2656
|
+
throw unreadable();
|
|
2657
|
+
}
|
|
2658
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw unreadable();
|
|
2659
|
+
const record = parsed;
|
|
2660
|
+
if (typeof record.libraryId !== "string" || typeof record.key !== "string") throw unreadable();
|
|
2661
|
+
return { libraryId: record.libraryId, key: record.key };
|
|
2662
|
+
}
|
|
2663
|
+
function writeCredentials(cwd, stored) {
|
|
2664
|
+
const path = join(cwd, CREDENTIALS_NAME);
|
|
2665
|
+
const replaced = existsSync(path);
|
|
2666
|
+
const body = { libraryId: stored.libraryId, key: stored.key };
|
|
2667
|
+
writeFileSync(path, `${JSON.stringify(body, null, 2)}
|
|
2668
|
+
`, { mode: 384 });
|
|
2669
|
+
chmodSync(path, 384);
|
|
2670
|
+
return { replaced };
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
// src/detect.ts
|
|
2674
|
+
import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
|
|
2675
|
+
import { join as join2 } from "node:path";
|
|
2676
|
+
var CODE_SYNTAX_KEY = {
|
|
2677
|
+
web: "WEB",
|
|
2678
|
+
ios: "iOS",
|
|
2679
|
+
android: "ANDROID",
|
|
2680
|
+
flutter: null
|
|
2681
|
+
};
|
|
2682
|
+
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2683
|
+
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2684
|
+
var uniq = (xs) => [...new Set(xs)];
|
|
2685
|
+
function readPackageJson(cwd) {
|
|
2686
|
+
const path = join2(cwd, "package.json");
|
|
2687
|
+
if (!existsSync2(path)) return null;
|
|
2688
|
+
let parsed;
|
|
2689
|
+
try {
|
|
2690
|
+
parsed = JSON.parse(readFileSync2(path, "utf8"));
|
|
2691
|
+
} catch {
|
|
2692
|
+
return null;
|
|
2693
|
+
}
|
|
2694
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2695
|
+
const record = parsed;
|
|
2696
|
+
const deps = {};
|
|
2697
|
+
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2698
|
+
const block = record[field];
|
|
2699
|
+
if (typeof block !== "object" || block === null) continue;
|
|
2700
|
+
for (const [name, range] of Object.entries(block)) {
|
|
2701
|
+
if (typeof range === "string") deps[name] = range;
|
|
2702
|
+
}
|
|
2703
|
+
}
|
|
2704
|
+
return { deps };
|
|
2705
|
+
}
|
|
2706
|
+
function majorOf(range) {
|
|
2707
|
+
const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
|
|
2708
|
+
return m ? Number(m[1]) : null;
|
|
2709
|
+
}
|
|
2710
|
+
var DEP_SIGNALS = [
|
|
2711
|
+
{ dep: "react", platform: "web", framework: "react" },
|
|
2712
|
+
{ dep: "next", platform: "web", framework: "next" },
|
|
2713
|
+
{ dep: "vue", platform: "web", framework: "vue" },
|
|
2714
|
+
{ dep: "nuxt", platform: "web", framework: "nuxt" },
|
|
2715
|
+
{ dep: "svelte", platform: "web", framework: "svelte" },
|
|
2716
|
+
{ dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
|
|
2717
|
+
{ dep: "@angular/core", platform: "web", framework: "angular" },
|
|
2718
|
+
{ dep: "solid-js", platform: "web", framework: "solid" },
|
|
2719
|
+
{ dep: "lit", platform: "web", framework: "lit" },
|
|
2720
|
+
{ dep: "astro", platform: "web", framework: "astro" },
|
|
2721
|
+
{ dep: "react-native", platform: "ios", framework: "react-native" },
|
|
2722
|
+
{ dep: "expo", platform: "ios", framework: "expo" },
|
|
2723
|
+
{ dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
|
|
2724
|
+
{ dep: "styled-components", platform: "web", framework: "styled-components" },
|
|
2725
|
+
{ dep: "@emotion/react", platform: "web", framework: "emotion" },
|
|
2726
|
+
{ dep: "sass", platform: "web", framework: "sass" },
|
|
2727
|
+
{ dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
|
|
2728
|
+
{ dep: "@stitches/react", platform: "web", framework: "stitches" },
|
|
2729
|
+
{ dep: "@pandacss/dev", platform: "web", framework: "panda" },
|
|
2730
|
+
{ dep: "style-dictionary", tokenTool: "style-dictionary" },
|
|
2731
|
+
{ dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
|
|
2732
|
+
{ dep: "typescript", language: "typescript" }
|
|
2733
|
+
];
|
|
2734
|
+
var FILE_SIGNALS = [
|
|
2735
|
+
{ test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
|
|
2736
|
+
{ test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
|
|
2737
|
+
{ test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
|
|
2738
|
+
{ test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
|
|
2739
|
+
{ test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
|
|
2740
|
+
{ test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
|
|
2741
|
+
{ test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
|
|
2742
|
+
{ test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
|
|
2743
|
+
{ test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
|
|
2744
|
+
{ test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
|
|
2745
|
+
{ test: (n) => n === "go.mod", signal: "Go module", language: "go" },
|
|
2746
|
+
{ test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
|
|
2747
|
+
{ test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
|
|
2748
|
+
{ test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
|
|
2749
|
+
{ test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
|
|
2750
|
+
{ test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
|
|
2751
|
+
{ test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
|
|
2752
|
+
{ test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
|
|
2753
|
+
{ test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
|
|
2754
|
+
{ test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
|
|
2755
|
+
{ test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
|
|
2756
|
+
{ test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
|
|
2757
|
+
{ test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
|
|
2758
|
+
{ test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
|
|
2759
|
+
];
|
|
2760
|
+
function detectRepo(cwd) {
|
|
2761
|
+
const platforms = [];
|
|
2762
|
+
const languages = [];
|
|
2763
|
+
const frameworks = [];
|
|
2764
|
+
const tokenTools = [];
|
|
2765
|
+
const agents = [];
|
|
2766
|
+
const evidence = [];
|
|
2767
|
+
let styleDictionaryMajor = null;
|
|
2768
|
+
let names = [];
|
|
2769
|
+
try {
|
|
2770
|
+
names = readdirSync(cwd).sort();
|
|
2771
|
+
} catch {
|
|
2772
|
+
names = [];
|
|
2773
|
+
}
|
|
2774
|
+
for (const name of names) {
|
|
2775
|
+
for (const rule of FILE_SIGNALS) {
|
|
2776
|
+
if (!rule.test(name)) continue;
|
|
2777
|
+
evidence.push({ signal: rule.signal, file: name });
|
|
2778
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2779
|
+
if (rule.language) languages.push(rule.language);
|
|
2780
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2781
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2782
|
+
if (rule.agent) agents.push(rule.agent);
|
|
2783
|
+
}
|
|
2784
|
+
}
|
|
2785
|
+
if (existsSync2(join2(cwd, ".github", "copilot-instructions.md")) || existsSync2(join2(cwd, ".github", "instructions"))) {
|
|
2786
|
+
evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
|
|
2787
|
+
agents.push("copilot");
|
|
2788
|
+
}
|
|
2789
|
+
const pkg = readPackageJson(cwd);
|
|
2790
|
+
if (pkg) {
|
|
2791
|
+
for (const rule of DEP_SIGNALS) {
|
|
2792
|
+
const range = pkg.deps[rule.dep];
|
|
2793
|
+
if (range === void 0) continue;
|
|
2794
|
+
evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
|
|
2795
|
+
if (rule.platform) platforms.push(rule.platform);
|
|
2796
|
+
if (rule.framework) frameworks.push(rule.framework);
|
|
2797
|
+
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2798
|
+
if (rule.language) languages.push(rule.language);
|
|
2799
|
+
if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
|
|
2800
|
+
}
|
|
2801
|
+
if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
|
|
2802
|
+
}
|
|
2803
|
+
const order = (p) => PLATFORMS.indexOf(p);
|
|
2804
|
+
const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
|
|
2805
|
+
return {
|
|
2806
|
+
platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
|
|
2807
|
+
languages: uniq(languages).sort(),
|
|
2808
|
+
frameworks: uniq(frameworks).sort(),
|
|
2809
|
+
tokenTools: uniq(tokenTools).sort(),
|
|
2810
|
+
agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
|
|
2811
|
+
styleDictionaryMajor,
|
|
2812
|
+
evidence
|
|
2813
|
+
};
|
|
2814
|
+
}
|
|
2815
|
+
function isPlatform(value) {
|
|
2816
|
+
return PLATFORMS.includes(value);
|
|
2817
|
+
}
|
|
2818
|
+
function isAgentHost(value) {
|
|
2819
|
+
return AGENT_HOSTS.includes(value);
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
// src/outputs.ts
|
|
2823
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
2824
|
+
import { resolve as resolve2 } from "node:path";
|
|
2825
|
+
|
|
2826
|
+
// src/visibleDir.ts
|
|
2827
|
+
import {
|
|
2828
|
+
closeSync,
|
|
2829
|
+
existsSync as existsSync3,
|
|
2830
|
+
lstatSync,
|
|
2831
|
+
mkdirSync,
|
|
2832
|
+
openSync,
|
|
2833
|
+
readSync,
|
|
2834
|
+
readdirSync as readdirSync2,
|
|
2835
|
+
renameSync,
|
|
2836
|
+
rmSync,
|
|
2837
|
+
writeFileSync as writeFileSync2
|
|
2838
|
+
} from "node:fs";
|
|
2839
|
+
import { isAbsolute, join as join3, relative, resolve } from "node:path";
|
|
2840
|
+
var inside = (parent, child) => {
|
|
2841
|
+
const rel = relative(parent, child);
|
|
2842
|
+
return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
|
|
2843
|
+
};
|
|
2844
|
+
var isDotfile = (name) => name.startsWith(".");
|
|
2845
|
+
function carriesMarker(abs, marker) {
|
|
2846
|
+
const newlines = (marker.match(/\n/g) ?? []).length;
|
|
2847
|
+
const wantBytes = Buffer.byteLength(marker, "utf8") + newlines;
|
|
2848
|
+
const fd = openSync(abs, "r");
|
|
2849
|
+
try {
|
|
2850
|
+
const buf = Buffer.alloc(wantBytes);
|
|
2851
|
+
const read = readSync(fd, buf, 0, wantBytes, 0);
|
|
2852
|
+
const text = buf.subarray(0, read).toString("utf8").replace(/\r\n/g, "\n");
|
|
2853
|
+
return text.startsWith(marker);
|
|
2854
|
+
} finally {
|
|
2855
|
+
closeSync(fd);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
function visibleDirProblem(cwd, outDir, dir, marker, others, configKey) {
|
|
2859
|
+
const root = resolve(cwd);
|
|
2860
|
+
const abs = resolve(cwd, dir);
|
|
2861
|
+
if (!inside(root, abs) || abs === root) return `${dir} is outside this directory. Choose a path inside the repository.`;
|
|
2862
|
+
if (inside(resolve(cwd, outDir), abs)) return `${dir} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
|
|
2863
|
+
for (const other of others) {
|
|
2864
|
+
const otherAbs = resolve(cwd, other);
|
|
2865
|
+
if (inside(abs, otherAbs) || inside(otherAbs, abs)) return `${dir} and ${other} overlap. Give each output its own directory.`;
|
|
2866
|
+
}
|
|
2867
|
+
if (!existsSync3(abs)) return null;
|
|
2868
|
+
if (!lstatSync(abs).isDirectory()) return `${dir} exists and is not a directory. Choose another path or remove the file.`;
|
|
2869
|
+
const foreign = `${dir} holds files spec-layer did not write. Set ${configKey} in speclayer.json to another path, or move them.`;
|
|
2870
|
+
for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
|
|
2871
|
+
if (isDotfile(entry2.name)) continue;
|
|
2872
|
+
if (!entry2.isFile()) return foreign;
|
|
2873
|
+
try {
|
|
2874
|
+
if (!carriesMarker(join3(abs, entry2.name), marker)) return foreign;
|
|
2875
|
+
} catch {
|
|
2876
|
+
return `${dir}/${entry2.name} could not be read.`;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
return null;
|
|
2880
|
+
}
|
|
2881
|
+
function writeAtomically(abs, text) {
|
|
2882
|
+
const partial = `${abs}.partial`;
|
|
2883
|
+
writeFileSync2(partial, text);
|
|
2884
|
+
try {
|
|
2885
|
+
renameSync(partial, abs);
|
|
2886
|
+
} catch (err) {
|
|
2887
|
+
rmSync(partial, { force: true });
|
|
2888
|
+
throw err;
|
|
2889
|
+
}
|
|
2890
|
+
}
|
|
2891
|
+
function writeVisibleDir(cwd, dir, marker, files, last) {
|
|
2892
|
+
const abs = resolve(cwd, dir);
|
|
2893
|
+
const names = Object.keys(files).filter((n) => n !== last);
|
|
2894
|
+
if (last !== void 0 && last in files) names.push(last);
|
|
2895
|
+
if (names.length === 0 && !existsSync3(abs)) return [];
|
|
2896
|
+
mkdirSync(abs, { recursive: true });
|
|
2897
|
+
for (const name of names) writeAtomically(join3(abs, name), files[name]);
|
|
2898
|
+
const keep = new Set(names);
|
|
2899
|
+
for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
|
|
2900
|
+
if (isDotfile(entry2.name) || keep.has(entry2.name) || !entry2.isFile()) continue;
|
|
2901
|
+
const path = join3(abs, entry2.name);
|
|
2902
|
+
if (carriesMarker(path, marker)) rmSync(path);
|
|
2903
|
+
}
|
|
2904
|
+
return names;
|
|
2905
|
+
}
|
|
2906
|
+
|
|
2907
|
+
// src/outputs.ts
|
|
2908
|
+
var FORMATS = [
|
|
2909
|
+
{ platform: "web", format: "css", defaultPath: "tokens", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
|
|
2910
|
+
];
|
|
2911
|
+
var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
|
|
2912
|
+
var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
|
|
2913
|
+
function defaultOutputs(platforms) {
|
|
2914
|
+
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 }));
|
|
2915
|
+
}
|
|
2916
|
+
function withDefaults(existing, platforms) {
|
|
2917
|
+
const covered = new Set(existing.map((o) => o.platform));
|
|
2918
|
+
return [...existing, ...defaultOutputs(platforms.filter((p) => !covered.has(p)))];
|
|
2919
|
+
}
|
|
2920
|
+
var outputId = (o) => `${o.platform}-${o.format}`;
|
|
2921
|
+
function parseOutput(value, index) {
|
|
2922
|
+
const at = `speclayer.json outputs[${index}]`;
|
|
2923
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${at} must be an object.`);
|
|
2924
|
+
const r = value;
|
|
2925
|
+
if (typeof r.platform !== "string" || typeof r.format !== "string") {
|
|
2926
|
+
throw new Error(`${at} needs "platform" and "format". Known: ${knownFormats()}.`);
|
|
2927
|
+
}
|
|
2928
|
+
const spec = specOf(r.platform, r.format);
|
|
2929
|
+
if (!spec) throw new Error(`${at}: unknown platform/format "${r.platform}/${r.format}". Known: ${knownFormats()}.`);
|
|
2930
|
+
const str = (key) => {
|
|
2931
|
+
if (r[key] !== void 0 && typeof r[key] !== "string") throw new Error(`${at} "${key}" must be a string.`);
|
|
2932
|
+
return r[key];
|
|
2933
|
+
};
|
|
2934
|
+
const path = str("path") ?? spec.defaultPath;
|
|
2935
|
+
if (path === null) throw new Error(`${at} needs "path": ${spec.platform} has no default location.`);
|
|
2936
|
+
const nameCase = str("case");
|
|
2937
|
+
if (nameCase !== void 0 && !NAME_CASES.includes(nameCase)) {
|
|
2938
|
+
throw new Error(`${at} "case" takes ${NAME_CASES.join(", ")}.`);
|
|
2939
|
+
}
|
|
2940
|
+
const root = str("root");
|
|
2941
|
+
const modeSelector = str("modeSelector");
|
|
2942
|
+
let modes;
|
|
2943
|
+
if (r.modes !== void 0) {
|
|
2944
|
+
const m = r.modes;
|
|
2945
|
+
if (typeof m !== "object" || m === null || Array.isArray(m) || !Object.values(m).every((v) => typeof v === "string")) {
|
|
2946
|
+
throw new Error(`${at} "modes" must map collection names to selector strings.`);
|
|
2947
|
+
}
|
|
2948
|
+
modes = m;
|
|
2949
|
+
}
|
|
2950
|
+
return {
|
|
2951
|
+
platform: spec.platform,
|
|
2952
|
+
format: spec.format,
|
|
2953
|
+
path,
|
|
2954
|
+
case: nameCase ?? spec.defaultCase,
|
|
2955
|
+
...root !== void 0 ? { root } : {},
|
|
2956
|
+
...modeSelector !== void 0 ? { modeSelector } : {},
|
|
2957
|
+
...modes ? { modes } : {}
|
|
2958
|
+
};
|
|
2959
|
+
}
|
|
2960
|
+
function renderOutput(exp, o, header) {
|
|
2961
|
+
switch (o.format) {
|
|
2962
|
+
case "css":
|
|
2963
|
+
return cssOutput(exp, { ...header, platform: o.platform, format: o.format }, {
|
|
2964
|
+
case: o.case,
|
|
2965
|
+
...o.root !== void 0 ? { root: o.root } : {},
|
|
2966
|
+
...o.modeSelector !== void 0 ? { modeSelector: o.modeSelector } : {},
|
|
2967
|
+
...o.modes ? { modes: o.modes } : {}
|
|
2968
|
+
});
|
|
2969
|
+
default: {
|
|
2970
|
+
const exhaustive = o.format;
|
|
2971
|
+
return exhaustive;
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
function readIndexImports(cwd, o) {
|
|
2976
|
+
const path = resolve2(cwd, o.path, CSS_INDEX_FILE);
|
|
2977
|
+
let text;
|
|
2138
2978
|
try {
|
|
2139
|
-
|
|
2979
|
+
text = readFileSync3(path, "utf8");
|
|
2140
2980
|
} catch {
|
|
2141
|
-
|
|
2981
|
+
return null;
|
|
2142
2982
|
}
|
|
2143
|
-
|
|
2144
|
-
const record = parsed;
|
|
2145
|
-
if (typeof record.libraryId !== "string" || typeof record.key !== "string") throw unreadable();
|
|
2146
|
-
return { libraryId: record.libraryId, key: record.key };
|
|
2983
|
+
return [...text.matchAll(/^@import "\.\/([^"\n]+)";$/gm)].map((m) => m[1]);
|
|
2147
2984
|
}
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
const
|
|
2152
|
-
|
|
2153
|
-
`, { mode: 384 });
|
|
2154
|
-
chmodSync(path, 384);
|
|
2155
|
-
return { replaced };
|
|
2985
|
+
var LEGACY_CSS_PATH_NOTE = (path) => `${path} names a file; spec-layer 0.7.0 writes a directory. Set outputs[].path to a directory, for example "tokens", delete the old file, and pull again.`;
|
|
2986
|
+
function outputPathProblem(cwd, outDir, o, others = []) {
|
|
2987
|
+
if (/\.css$/i.test(o.path)) return LEGACY_CSS_PATH_NOTE(o.path);
|
|
2988
|
+
const marker = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
|
|
2989
|
+
return visibleDirProblem(cwd, outDir, o.path, marker, others, "outputs[].path");
|
|
2156
2990
|
}
|
|
2157
2991
|
|
|
2158
2992
|
// src/config.ts
|
|
2159
2993
|
var DEFAULT_API = "https://api.spec-layer.com";
|
|
2160
2994
|
var DEFAULT_OUT_DIR = ".speclayer";
|
|
2995
|
+
var DEFAULT_COMPONENT_SPECS_DIR = "component-specs";
|
|
2161
2996
|
var CONFIG_NAME = "speclayer.json";
|
|
2162
2997
|
var invalidConfig = () => new Error(`${CONFIG_NAME} is not valid JSON. Fix or delete it, then retry.`);
|
|
2163
2998
|
function parseInclude(value) {
|
|
2164
2999
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
|
|
2165
3000
|
const record = value;
|
|
2166
3001
|
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"))) {
|
|
3002
|
+
if (record.components !== void 0 && record.components !== null && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
|
|
2168
3003
|
throw invalidConfig();
|
|
2169
3004
|
}
|
|
2170
3005
|
return {
|
|
2171
3006
|
foundation: record.foundation === void 0 ? true : record.foundation,
|
|
2172
|
-
components: record.components
|
|
3007
|
+
components: record.components ?? null
|
|
2173
3008
|
};
|
|
2174
3009
|
}
|
|
2175
3010
|
function parseDtcg(value) {
|
|
@@ -2189,12 +3024,39 @@ function parseDtcg(value) {
|
|
|
2189
3024
|
}
|
|
2190
3025
|
return out;
|
|
2191
3026
|
}
|
|
3027
|
+
function parseComponentSpecsDir(value) {
|
|
3028
|
+
if (typeof value !== "string" || value.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
|
|
3029
|
+
let dir = value.replace(/\\/g, "/");
|
|
3030
|
+
if (dir.startsWith("./")) dir = dir.slice(2);
|
|
3031
|
+
dir = dir.replace(/\/+$/, "");
|
|
3032
|
+
if (dir.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
|
|
3033
|
+
return dir;
|
|
3034
|
+
}
|
|
3035
|
+
function parsePlatforms(value) {
|
|
3036
|
+
if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
|
|
3037
|
+
throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
|
|
3038
|
+
}
|
|
3039
|
+
return [...new Set(value)];
|
|
3040
|
+
}
|
|
3041
|
+
function parseOutputs(value) {
|
|
3042
|
+
if (!Array.isArray(value)) throw new Error('speclayer.json "outputs" must be an array.');
|
|
3043
|
+
const outputs = value.map((v, i) => parseOutput(v, i));
|
|
3044
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3045
|
+
for (const output of outputs) {
|
|
3046
|
+
const key = `${output.platform}/${output.format}`;
|
|
3047
|
+
if (seen.has(key)) {
|
|
3048
|
+
throw new Error(`speclayer.json "outputs" lists ${output.platform}/${output.format} more than once. Keep one entry per platform and format.`);
|
|
3049
|
+
}
|
|
3050
|
+
seen.add(key);
|
|
3051
|
+
}
|
|
3052
|
+
return outputs;
|
|
3053
|
+
}
|
|
2192
3054
|
function readConfig(cwd) {
|
|
2193
|
-
const path =
|
|
2194
|
-
if (!
|
|
3055
|
+
const path = join4(cwd, CONFIG_NAME);
|
|
3056
|
+
if (!existsSync4(path)) return null;
|
|
2195
3057
|
let parsed;
|
|
2196
3058
|
try {
|
|
2197
|
-
parsed = JSON.parse(
|
|
3059
|
+
parsed = JSON.parse(readFileSync4(path, "utf8"));
|
|
2198
3060
|
} catch {
|
|
2199
3061
|
throw invalidConfig();
|
|
2200
3062
|
}
|
|
@@ -2203,24 +3065,30 @@ function readConfig(cwd) {
|
|
|
2203
3065
|
return {
|
|
2204
3066
|
...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
|
|
2205
3067
|
...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
|
|
3068
|
+
...record.componentSpecsDir !== void 0 ? { componentSpecsDir: parseComponentSpecsDir(record.componentSpecsDir) } : {},
|
|
2206
3069
|
...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
|
|
2207
|
-
...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
|
|
3070
|
+
...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
|
|
3071
|
+
...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
|
|
3072
|
+
...record.outputs !== void 0 ? { outputs: parseOutputs(record.outputs) } : {}
|
|
2208
3073
|
};
|
|
2209
3074
|
}
|
|
2210
3075
|
function writeConfig(cwd, config) {
|
|
2211
3076
|
const body = {
|
|
2212
3077
|
libraryId: config.libraryId,
|
|
2213
3078
|
outDir: config.outDir,
|
|
3079
|
+
...config.componentSpecsDir ? { componentSpecsDir: config.componentSpecsDir } : {},
|
|
2214
3080
|
...config.include ? { include: config.include } : {},
|
|
2215
|
-
...config.dtcg ? { dtcg: config.dtcg } : {}
|
|
3081
|
+
...config.dtcg ? { dtcg: config.dtcg } : {},
|
|
3082
|
+
...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
|
|
3083
|
+
...config.outputs ? { outputs: config.outputs } : {}
|
|
2216
3084
|
};
|
|
2217
|
-
|
|
3085
|
+
writeFileSync3(join4(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
|
|
2218
3086
|
`);
|
|
2219
3087
|
}
|
|
2220
3088
|
function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
2221
3089
|
const config = readConfig(cwd);
|
|
2222
3090
|
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
2223
|
-
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(
|
|
3091
|
+
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join4(cwd, outDir));
|
|
2224
3092
|
const supplied = flags.key || env.SPEC_LAYER_KEY || null;
|
|
2225
3093
|
let storedKey = null;
|
|
2226
3094
|
let storedKeyFor;
|
|
@@ -2234,11 +3102,14 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
|
2234
3102
|
return {
|
|
2235
3103
|
libraryId,
|
|
2236
3104
|
outDir,
|
|
3105
|
+
componentSpecsDir: config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
|
|
2237
3106
|
// A trailing slash would build "//v1/..." paths the proxy router 404s on.
|
|
2238
3107
|
api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
|
|
2239
3108
|
key: supplied ?? storedKey,
|
|
2240
3109
|
...config?.include ? { include: config.include } : {},
|
|
2241
3110
|
...config?.dtcg ? { dtcg: config.dtcg } : {},
|
|
3111
|
+
...config?.platforms ? { platforms: config.platforms } : {},
|
|
3112
|
+
...config?.outputs ? { outputs: config.outputs } : {},
|
|
2242
3113
|
...storedKeyFor ? { storedKeyFor } : {}
|
|
2243
3114
|
};
|
|
2244
3115
|
}
|
|
@@ -2277,8 +3148,8 @@ async function fetchBundle(opts) {
|
|
|
2277
3148
|
}
|
|
2278
3149
|
|
|
2279
3150
|
// src/files.ts
|
|
2280
|
-
import { mkdirSync, writeFileSync as
|
|
2281
|
-
import { join as
|
|
3151
|
+
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
|
|
3152
|
+
import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
|
|
2282
3153
|
|
|
2283
3154
|
// src/selection.ts
|
|
2284
3155
|
var DEFAULT_SELECTION = { foundation: true, components: null };
|
|
@@ -2316,20 +3187,29 @@ function slugify(name) {
|
|
|
2316
3187
|
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
2317
3188
|
return slug2 || "component";
|
|
2318
3189
|
}
|
|
3190
|
+
var COMPONENT_SPEC_MARKER = "spec_layer:\n kind: component";
|
|
2319
3191
|
function readManifest(outDir) {
|
|
2320
|
-
const path =
|
|
2321
|
-
if (!
|
|
3192
|
+
const path = join5(outDir, "manifest.json");
|
|
3193
|
+
if (!existsSync5(path)) return null;
|
|
2322
3194
|
try {
|
|
2323
|
-
|
|
3195
|
+
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
3196
|
+
parsed.artifacts = parsed.artifacts.map((artifact) => {
|
|
3197
|
+
const { aiPath, ...rest } = artifact;
|
|
3198
|
+
return {
|
|
3199
|
+
...rest,
|
|
3200
|
+
path: rest.path ?? aiPath ?? null
|
|
3201
|
+
};
|
|
3202
|
+
});
|
|
3203
|
+
return parsed;
|
|
2324
3204
|
} catch {
|
|
2325
3205
|
return null;
|
|
2326
3206
|
}
|
|
2327
3207
|
}
|
|
2328
3208
|
function readLocalBundle(outDir) {
|
|
2329
|
-
const path =
|
|
2330
|
-
if (!
|
|
3209
|
+
const path = join5(outDir, "bundle.json");
|
|
3210
|
+
if (!existsSync5(path)) return null;
|
|
2331
3211
|
try {
|
|
2332
|
-
return parseBundle(
|
|
3212
|
+
return parseBundle(readFileSync5(path, "utf8"));
|
|
2333
3213
|
} catch {
|
|
2334
3214
|
throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
|
|
2335
3215
|
}
|
|
@@ -2356,11 +3236,11 @@ function componentSlugs(bundle) {
|
|
|
2356
3236
|
});
|
|
2357
3237
|
}
|
|
2358
3238
|
function assertReplaceable(outDir, cwd) {
|
|
2359
|
-
const rel =
|
|
2360
|
-
if (rel === "" || rel.startsWith("..") ||
|
|
3239
|
+
const rel = relative2(resolve3(cwd), resolve3(outDir));
|
|
3240
|
+
if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
|
|
2361
3241
|
throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
|
|
2362
3242
|
}
|
|
2363
|
-
if (
|
|
3243
|
+
if (existsSync5(outDir) && !existsSync5(join5(outDir, "manifest.json")) && readdirSync3(outDir).length > 0) {
|
|
2364
3244
|
throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
|
|
2365
3245
|
}
|
|
2366
3246
|
}
|
|
@@ -2369,44 +3249,70 @@ function writeBundleFiles(opts) {
|
|
|
2369
3249
|
const selection = opts.selection ?? DEFAULT_SELECTION;
|
|
2370
3250
|
const selected = selectComponents(opts.bundle, selection);
|
|
2371
3251
|
const slugs = componentSlugs(opts.bundle);
|
|
3252
|
+
const outputs = opts.outputs ?? [];
|
|
3253
|
+
const componentSpecsDir = opts.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
|
|
3254
|
+
const outDirRel = relative2(resolve3(opts.cwd), resolve3(opts.outDir)).split(sep).join("/");
|
|
3255
|
+
const outputPaths = outputs.map((o) => o.path);
|
|
3256
|
+
const specsProblem = visibleDirProblem(opts.cwd, outDirRel, componentSpecsDir, COMPONENT_SPEC_MARKER, outputPaths, "componentSpecsDir");
|
|
3257
|
+
if (specsProblem) throw new Error(specsProblem);
|
|
3258
|
+
for (const o of outputs) {
|
|
3259
|
+
const problem = outputPathProblem(opts.cwd, outDirRel, o, [componentSpecsDir, ...outputPaths.filter((p) => p !== o.path)]);
|
|
3260
|
+
if (problem) throw new Error(problem);
|
|
3261
|
+
}
|
|
3262
|
+
const briefs = {};
|
|
3263
|
+
opts.bundle.components.forEach((component, i) => {
|
|
3264
|
+
if (!selected[i]) return;
|
|
3265
|
+
if (!component.ai.startsWith(COMPONENT_SPEC_MARKER)) {
|
|
3266
|
+
throw new Error(`The published brief for ${component.name} does not begin with the Spec Layer marker. Republish from the plugin, then pull again.`);
|
|
3267
|
+
}
|
|
3268
|
+
briefs[`${slugs[i]}.yaml`] = component.ai;
|
|
3269
|
+
});
|
|
2372
3270
|
const staging = `${opts.outDir}.partial`;
|
|
2373
|
-
|
|
3271
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
2374
3272
|
const written = [];
|
|
3273
|
+
const deliverables = [];
|
|
3274
|
+
const json = (v) => `${JSON.stringify(v, null, 2)}
|
|
3275
|
+
`;
|
|
2375
3276
|
const put = (rel, content) => {
|
|
2376
|
-
const path =
|
|
2377
|
-
|
|
2378
|
-
|
|
3277
|
+
const path = join5(staging, rel);
|
|
3278
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
3279
|
+
writeFileSync4(path, content);
|
|
2379
3280
|
written.push(rel);
|
|
2380
3281
|
};
|
|
2381
3282
|
try {
|
|
2382
3283
|
put("bundle.json", opts.raw);
|
|
2383
3284
|
const artifacts = [];
|
|
2384
3285
|
if (opts.bundle.foundation) {
|
|
2385
|
-
let
|
|
3286
|
+
let path = null;
|
|
2386
3287
|
if (selection.foundation) {
|
|
2387
3288
|
const artifact = opts.bundle.foundation.artifact;
|
|
2388
3289
|
if (validateLevel1(artifact).some((d) => d.severity === "error")) {
|
|
2389
3290
|
throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
|
|
2390
3291
|
}
|
|
2391
|
-
const
|
|
2392
|
-
for (const [name, text] of Object.entries(
|
|
2393
|
-
|
|
3292
|
+
const exp = foundationDtcg(artifact, opts.dtcg ?? {});
|
|
3293
|
+
for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
|
|
3294
|
+
path = `${outDirRel}/tokens/resolver.json`;
|
|
3295
|
+
const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
|
|
3296
|
+
for (const output of outputs) {
|
|
3297
|
+
const rendered = renderOutput(exp, output, header);
|
|
3298
|
+
put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
|
|
3299
|
+
put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
|
|
3300
|
+
deliverables.push({ output, files: rendered.files });
|
|
3301
|
+
}
|
|
2394
3302
|
}
|
|
2395
3303
|
artifacts.push({
|
|
2396
3304
|
kind: "foundation",
|
|
2397
3305
|
name: "foundation",
|
|
2398
3306
|
contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash,
|
|
2399
|
-
|
|
3307
|
+
path
|
|
2400
3308
|
});
|
|
2401
3309
|
}
|
|
2402
3310
|
opts.bundle.components.forEach((component, i) => {
|
|
2403
|
-
const aiPath = selected[i] ? `ai/components/${slugs[i]}.yaml` : null;
|
|
2404
|
-
if (aiPath) put(aiPath, component.ai);
|
|
2405
3311
|
artifacts.push({
|
|
2406
3312
|
kind: "component",
|
|
2407
3313
|
name: component.name,
|
|
2408
3314
|
contentHash: component.artifact.spec_layer.export.content_hash,
|
|
2409
|
-
|
|
3315
|
+
path: selected[i] ? `${componentSpecsDir}/${slugs[i]}.yaml` : null
|
|
2410
3316
|
});
|
|
2411
3317
|
});
|
|
2412
3318
|
const manifest = {
|
|
@@ -2416,24 +3322,31 @@ function writeBundleFiles(opts) {
|
|
|
2416
3322
|
pluginVersion: opts.bundle.pluginVersion,
|
|
2417
3323
|
extractorVersion: opts.bundle.extractorVersion,
|
|
2418
3324
|
selection,
|
|
3325
|
+
componentSpecsDir,
|
|
2419
3326
|
artifacts,
|
|
2420
|
-
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
|
|
3327
|
+
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
|
|
3328
|
+
...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
|
|
3329
|
+
...opts.outputs ? { outputs: opts.outputs } : {}
|
|
2421
3330
|
};
|
|
2422
|
-
put("manifest.json",
|
|
2423
|
-
`);
|
|
3331
|
+
put("manifest.json", json(manifest));
|
|
2424
3332
|
} catch (err) {
|
|
2425
|
-
|
|
3333
|
+
rmSync2(staging, { recursive: true, force: true });
|
|
2426
3334
|
throw err;
|
|
2427
3335
|
}
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
3336
|
+
rmSync2(opts.outDir, { recursive: true, force: true });
|
|
3337
|
+
renameSync2(staging, opts.outDir);
|
|
3338
|
+
const componentSpecs = { path: componentSpecsDir, files: writeVisibleDir(opts.cwd, componentSpecsDir, COMPONENT_SPEC_MARKER, briefs) };
|
|
3339
|
+
const outputResults = [];
|
|
3340
|
+
for (const d of deliverables) {
|
|
3341
|
+
outputResults.push({ path: d.output.path, files: writeVisibleDir(opts.cwd, d.output.path, CSS_HEADER_PREFIX, d.files, CSS_INDEX_FILE) });
|
|
3342
|
+
}
|
|
3343
|
+
return { written, componentSpecs, outputs: outputResults };
|
|
2431
3344
|
}
|
|
2432
3345
|
|
|
2433
3346
|
// src/gitignore.ts
|
|
2434
|
-
import { readFileSync as
|
|
3347
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
|
|
2435
3348
|
import { spawnSync } from "node:child_process";
|
|
2436
|
-
import { join as
|
|
3349
|
+
import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
|
|
2437
3350
|
var COMMENT = "# Spec Layer pull key, not for committing";
|
|
2438
3351
|
function git(cwd, args) {
|
|
2439
3352
|
const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
@@ -2441,9 +3354,9 @@ function git(cwd, args) {
|
|
|
2441
3354
|
return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
|
|
2442
3355
|
}
|
|
2443
3356
|
function insideWorkTreeWithoutGit(cwd) {
|
|
2444
|
-
let dir =
|
|
3357
|
+
let dir = resolve4(cwd);
|
|
2445
3358
|
for (; ; ) {
|
|
2446
|
-
if (
|
|
3359
|
+
if (existsSync6(join6(dir, ".git"))) return true;
|
|
2447
3360
|
const parent = dirname2(dir);
|
|
2448
3361
|
if (parent === dir) return false;
|
|
2449
3362
|
dir = parent;
|
|
@@ -2460,18 +3373,18 @@ function ensureIgnored(cwd, fileName) {
|
|
|
2460
3373
|
if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
|
|
2461
3374
|
const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
|
|
2462
3375
|
if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
|
|
2463
|
-
const path =
|
|
2464
|
-
const existed =
|
|
3376
|
+
const path = join6(cwd, ".gitignore");
|
|
3377
|
+
const existed = existsSync6(path);
|
|
2465
3378
|
try {
|
|
2466
3379
|
if (!existed) {
|
|
2467
|
-
|
|
3380
|
+
writeFileSync5(path, `${COMMENT}
|
|
2468
3381
|
${fileName}
|
|
2469
3382
|
`);
|
|
2470
3383
|
} else {
|
|
2471
|
-
const body =
|
|
3384
|
+
const body = readFileSync6(path, "utf8");
|
|
2472
3385
|
if (!hasEntryLine(body, fileName)) {
|
|
2473
3386
|
const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
|
|
2474
|
-
|
|
3387
|
+
writeFileSync5(path, `${body}${lead}${COMMENT}
|
|
2475
3388
|
${fileName}
|
|
2476
3389
|
`);
|
|
2477
3390
|
}
|
|
@@ -2484,158 +3397,9 @@ ${fileName}
|
|
|
2484
3397
|
return existed ? { kind: "added" } : { kind: "created" };
|
|
2485
3398
|
}
|
|
2486
3399
|
|
|
2487
|
-
// src/detect.ts
|
|
2488
|
-
import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
|
|
2489
|
-
import { join as join5 } from "node:path";
|
|
2490
|
-
var CODE_SYNTAX_KEY = {
|
|
2491
|
-
web: "WEB",
|
|
2492
|
-
ios: "iOS",
|
|
2493
|
-
android: "ANDROID",
|
|
2494
|
-
flutter: null
|
|
2495
|
-
};
|
|
2496
|
-
var PLATFORMS = ["web", "ios", "android", "flutter"];
|
|
2497
|
-
var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
|
|
2498
|
-
var uniq = (xs) => [...new Set(xs)];
|
|
2499
|
-
function readPackageJson(cwd) {
|
|
2500
|
-
const path = join5(cwd, "package.json");
|
|
2501
|
-
if (!existsSync5(path)) return null;
|
|
2502
|
-
let parsed;
|
|
2503
|
-
try {
|
|
2504
|
-
parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
2505
|
-
} catch {
|
|
2506
|
-
return null;
|
|
2507
|
-
}
|
|
2508
|
-
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2509
|
-
const record = parsed;
|
|
2510
|
-
const deps = {};
|
|
2511
|
-
for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
2512
|
-
const block = record[field];
|
|
2513
|
-
if (typeof block !== "object" || block === null) continue;
|
|
2514
|
-
for (const [name, range] of Object.entries(block)) {
|
|
2515
|
-
if (typeof range === "string") deps[name] = range;
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
return { deps };
|
|
2519
|
-
}
|
|
2520
|
-
function majorOf(range) {
|
|
2521
|
-
const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
|
|
2522
|
-
return m ? Number(m[1]) : null;
|
|
2523
|
-
}
|
|
2524
|
-
var DEP_SIGNALS = [
|
|
2525
|
-
{ dep: "react", platform: "web", framework: "react" },
|
|
2526
|
-
{ dep: "next", platform: "web", framework: "next" },
|
|
2527
|
-
{ dep: "vue", platform: "web", framework: "vue" },
|
|
2528
|
-
{ dep: "nuxt", platform: "web", framework: "nuxt" },
|
|
2529
|
-
{ dep: "svelte", platform: "web", framework: "svelte" },
|
|
2530
|
-
{ dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
|
|
2531
|
-
{ dep: "@angular/core", platform: "web", framework: "angular" },
|
|
2532
|
-
{ dep: "solid-js", platform: "web", framework: "solid" },
|
|
2533
|
-
{ dep: "lit", platform: "web", framework: "lit" },
|
|
2534
|
-
{ dep: "astro", platform: "web", framework: "astro" },
|
|
2535
|
-
{ dep: "react-native", platform: "ios", framework: "react-native" },
|
|
2536
|
-
{ dep: "expo", platform: "ios", framework: "expo" },
|
|
2537
|
-
{ dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
|
|
2538
|
-
{ dep: "styled-components", platform: "web", framework: "styled-components" },
|
|
2539
|
-
{ dep: "@emotion/react", platform: "web", framework: "emotion" },
|
|
2540
|
-
{ dep: "sass", platform: "web", framework: "sass" },
|
|
2541
|
-
{ dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
|
|
2542
|
-
{ dep: "@stitches/react", platform: "web", framework: "stitches" },
|
|
2543
|
-
{ dep: "@pandacss/dev", platform: "web", framework: "panda" },
|
|
2544
|
-
{ dep: "style-dictionary", tokenTool: "style-dictionary" },
|
|
2545
|
-
{ dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
|
|
2546
|
-
{ dep: "typescript", language: "typescript" }
|
|
2547
|
-
];
|
|
2548
|
-
var FILE_SIGNALS = [
|
|
2549
|
-
{ test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
|
|
2550
|
-
{ test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
|
|
2551
|
-
{ test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
|
|
2552
|
-
{ test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
|
|
2553
|
-
{ test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
|
|
2554
|
-
{ test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
|
|
2555
|
-
{ test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
|
|
2556
|
-
{ test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
|
|
2557
|
-
{ test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
|
|
2558
|
-
{ test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
|
|
2559
|
-
{ test: (n) => n === "go.mod", signal: "Go module", language: "go" },
|
|
2560
|
-
{ test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
|
|
2561
|
-
{ test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
|
|
2562
|
-
{ test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
|
|
2563
|
-
{ test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
|
|
2564
|
-
{ test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
|
|
2565
|
-
{ test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
|
|
2566
|
-
{ test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
|
|
2567
|
-
{ test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
|
|
2568
|
-
{ test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
|
|
2569
|
-
{ test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
|
|
2570
|
-
{ test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
|
|
2571
|
-
{ test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
|
|
2572
|
-
{ test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
|
|
2573
|
-
];
|
|
2574
|
-
function detectRepo(cwd) {
|
|
2575
|
-
const platforms = [];
|
|
2576
|
-
const languages = [];
|
|
2577
|
-
const frameworks = [];
|
|
2578
|
-
const tokenTools = [];
|
|
2579
|
-
const agents = [];
|
|
2580
|
-
const evidence = [];
|
|
2581
|
-
let styleDictionaryMajor = null;
|
|
2582
|
-
let names = [];
|
|
2583
|
-
try {
|
|
2584
|
-
names = readdirSync2(cwd).sort();
|
|
2585
|
-
} catch {
|
|
2586
|
-
names = [];
|
|
2587
|
-
}
|
|
2588
|
-
for (const name of names) {
|
|
2589
|
-
for (const rule of FILE_SIGNALS) {
|
|
2590
|
-
if (!rule.test(name)) continue;
|
|
2591
|
-
evidence.push({ signal: rule.signal, file: name });
|
|
2592
|
-
if (rule.platform) platforms.push(rule.platform);
|
|
2593
|
-
if (rule.language) languages.push(rule.language);
|
|
2594
|
-
if (rule.framework) frameworks.push(rule.framework);
|
|
2595
|
-
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2596
|
-
if (rule.agent) agents.push(rule.agent);
|
|
2597
|
-
}
|
|
2598
|
-
}
|
|
2599
|
-
if (existsSync5(join5(cwd, ".github", "copilot-instructions.md")) || existsSync5(join5(cwd, ".github", "instructions"))) {
|
|
2600
|
-
evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
|
|
2601
|
-
agents.push("copilot");
|
|
2602
|
-
}
|
|
2603
|
-
const pkg = readPackageJson(cwd);
|
|
2604
|
-
if (pkg) {
|
|
2605
|
-
for (const rule of DEP_SIGNALS) {
|
|
2606
|
-
const range = pkg.deps[rule.dep];
|
|
2607
|
-
if (range === void 0) continue;
|
|
2608
|
-
evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
|
|
2609
|
-
if (rule.platform) platforms.push(rule.platform);
|
|
2610
|
-
if (rule.framework) frameworks.push(rule.framework);
|
|
2611
|
-
if (rule.tokenTool) tokenTools.push(rule.tokenTool);
|
|
2612
|
-
if (rule.language) languages.push(rule.language);
|
|
2613
|
-
if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
|
|
2614
|
-
}
|
|
2615
|
-
if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
|
|
2616
|
-
}
|
|
2617
|
-
const order = (p) => PLATFORMS.indexOf(p);
|
|
2618
|
-
const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
|
|
2619
|
-
return {
|
|
2620
|
-
platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
|
|
2621
|
-
languages: uniq(languages).sort(),
|
|
2622
|
-
frameworks: uniq(frameworks).sort(),
|
|
2623
|
-
tokenTools: uniq(tokenTools).sort(),
|
|
2624
|
-
agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
|
|
2625
|
-
styleDictionaryMajor,
|
|
2626
|
-
evidence
|
|
2627
|
-
};
|
|
2628
|
-
}
|
|
2629
|
-
function isPlatform(value) {
|
|
2630
|
-
return PLATFORMS.includes(value);
|
|
2631
|
-
}
|
|
2632
|
-
function isAgentHost(value) {
|
|
2633
|
-
return AGENT_HOSTS.includes(value);
|
|
2634
|
-
}
|
|
2635
|
-
|
|
2636
3400
|
// src/skill.ts
|
|
2637
|
-
import { existsSync as
|
|
2638
|
-
import { dirname as dirname3, join as
|
|
3401
|
+
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
|
|
3402
|
+
import { dirname as dirname3, join as join7 } from "node:path";
|
|
2639
3403
|
|
|
2640
3404
|
// src/tools.ts
|
|
2641
3405
|
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
@@ -2643,18 +3407,25 @@ var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
|
|
|
2643
3407
|
var TOOLS = [
|
|
2644
3408
|
{
|
|
2645
3409
|
name: "setup",
|
|
2646
|
-
usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
3410
|
+
usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
2647
3411
|
summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
|
|
2648
3412
|
when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
|
|
2649
3413
|
network: true,
|
|
2650
3414
|
needsKey: true,
|
|
2651
|
-
writes: [
|
|
3415
|
+
writes: [
|
|
3416
|
+
"speclayer.json",
|
|
3417
|
+
"speclayer.local.json",
|
|
3418
|
+
".gitignore (one line, when inside a git repo)",
|
|
3419
|
+
"<outDir>/",
|
|
3420
|
+
"outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
|
|
3421
|
+
"componentSpecsDir from speclayer.json (default component-specs/), written in place"
|
|
3422
|
+
],
|
|
2652
3423
|
exits: OK_OR_ERROR
|
|
2653
3424
|
},
|
|
2654
3425
|
{
|
|
2655
3426
|
name: "init",
|
|
2656
|
-
usage: "spec-layer init --id lib_... [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
2657
|
-
summary: "Writes speclayer.json so later commands need no flags. Stores no key and reaches no server.",
|
|
3427
|
+
usage: "spec-layer init --id lib_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3428
|
+
summary: "Writes speclayer.json, with the platforms and default outputs, so later commands need no flags. Stores no key and reaches no server.",
|
|
2658
3429
|
when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
|
|
2659
3430
|
network: false,
|
|
2660
3431
|
needsKey: false,
|
|
@@ -2663,12 +3434,16 @@ var TOOLS = [
|
|
|
2663
3434
|
},
|
|
2664
3435
|
{
|
|
2665
3436
|
name: "pull",
|
|
2666
|
-
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--only foundation|components] [--component NAME]...",
|
|
2667
|
-
summary: "Fetches the published library and writes
|
|
2668
|
-
when: "After setup, whenever status says the local copy is behind, or after changing the include or
|
|
3437
|
+
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3438
|
+
summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path.",
|
|
3439
|
+
when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks.",
|
|
2669
3440
|
network: true,
|
|
2670
3441
|
needsKey: true,
|
|
2671
|
-
writes: [
|
|
3442
|
+
writes: [
|
|
3443
|
+
"<outDir>/",
|
|
3444
|
+
"outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
|
|
3445
|
+
"componentSpecsDir from speclayer.json (default component-specs/), written in place"
|
|
3446
|
+
],
|
|
2672
3447
|
exits: OK_OR_ERROR
|
|
2673
3448
|
},
|
|
2674
3449
|
{
|
|
@@ -2713,7 +3488,7 @@ var TOOLS = [
|
|
|
2713
3488
|
},
|
|
2714
3489
|
{
|
|
2715
3490
|
name: "skill",
|
|
2716
|
-
usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter] [--json] [--out DIR]",
|
|
3491
|
+
usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter]... [--json] [--out DIR]",
|
|
2717
3492
|
summary: "Prints a guide for a coding agent, adapted to this repository's stack and to the last pull; --install writes it where the agent reads instructions.",
|
|
2718
3493
|
when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
|
|
2719
3494
|
network: false,
|
|
@@ -2768,42 +3543,42 @@ function countNumberTokens(tree) {
|
|
|
2768
3543
|
return n;
|
|
2769
3544
|
}
|
|
2770
3545
|
function readJson(path) {
|
|
2771
|
-
if (!
|
|
3546
|
+
if (!existsSync7(path)) return null;
|
|
2772
3547
|
try {
|
|
2773
|
-
return JSON.parse(
|
|
3548
|
+
return JSON.parse(readFileSync7(path, "utf8"));
|
|
2774
3549
|
} catch {
|
|
2775
3550
|
return null;
|
|
2776
3551
|
}
|
|
2777
3552
|
}
|
|
2778
3553
|
function summarizePull(cwd, outDir, manifest) {
|
|
2779
3554
|
if (!manifest) return null;
|
|
2780
|
-
const absOut =
|
|
2781
|
-
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.
|
|
3555
|
+
const absOut = join7(cwd, outDir);
|
|
3556
|
+
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path }));
|
|
2782
3557
|
const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
|
|
2783
3558
|
let foundation = null;
|
|
2784
3559
|
if (foundationEntry) {
|
|
2785
|
-
const tokensDir =
|
|
2786
|
-
const resolver = readJson(
|
|
2787
|
-
const
|
|
3560
|
+
const tokensDir = join7(absOut, "tokens");
|
|
3561
|
+
const resolver = readJson(join7(tokensDir, "resolver.json"));
|
|
3562
|
+
const report2 = readJson(join7(tokensDir, "report.json"));
|
|
2788
3563
|
let tokenFiles = [];
|
|
2789
3564
|
try {
|
|
2790
|
-
tokenFiles =
|
|
3565
|
+
tokenFiles = readdirSync4(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
|
|
2791
3566
|
} catch {
|
|
2792
3567
|
tokenFiles = [];
|
|
2793
3568
|
}
|
|
2794
3569
|
let unitlessNumbers = 0;
|
|
2795
3570
|
for (const file of tokenFiles) {
|
|
2796
3571
|
if (file.startsWith("styles.")) continue;
|
|
2797
|
-
unitlessNumbers += countNumberTokens(readJson(
|
|
3572
|
+
unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
|
|
2798
3573
|
}
|
|
2799
3574
|
const reportCounts = {};
|
|
2800
|
-
if (Array.isArray(
|
|
2801
|
-
for (const entry2 of
|
|
3575
|
+
if (Array.isArray(report2)) {
|
|
3576
|
+
for (const entry2 of report2) {
|
|
2802
3577
|
if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
|
|
2803
3578
|
}
|
|
2804
3579
|
}
|
|
2805
3580
|
foundation = {
|
|
2806
|
-
written: foundationEntry.
|
|
3581
|
+
written: foundationEntry.path !== null && resolver !== null,
|
|
2807
3582
|
sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
|
|
2808
3583
|
modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
|
|
2809
3584
|
name,
|
|
@@ -2820,8 +3595,33 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
2820
3595
|
libraryId: manifest.libraryId,
|
|
2821
3596
|
publishedAt: manifest.publishedAt,
|
|
2822
3597
|
pluginVersion: manifest.pluginVersion,
|
|
3598
|
+
componentSpecsDir: manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
|
|
2823
3599
|
components,
|
|
2824
|
-
foundation
|
|
3600
|
+
foundation,
|
|
3601
|
+
outputs: (manifest.outputs ?? []).map((o) => {
|
|
3602
|
+
const mapPath = join7(absOut, "outputs", `${o.platform}-${o.format}.map.json`);
|
|
3603
|
+
const map = readJson(mapPath);
|
|
3604
|
+
const imports = map ? readIndexImports(cwd, o) : null;
|
|
3605
|
+
const files = imports !== null ? [...imports, CSS_INDEX_FILE] : [];
|
|
3606
|
+
return {
|
|
3607
|
+
platform: o.platform,
|
|
3608
|
+
format: o.format,
|
|
3609
|
+
path: o.path,
|
|
3610
|
+
case: o.case,
|
|
3611
|
+
modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
|
|
3612
|
+
modes: o.modes ?? {},
|
|
3613
|
+
// index.css must be readable, not just the map, or a deleted index.css
|
|
3614
|
+
// (with the map still on disk from an interrupted pull) would report
|
|
3615
|
+
// written with an empty file list, a sentence that claims files exist.
|
|
3616
|
+
written: map !== null && imports !== null,
|
|
3617
|
+
// Distinguishes "the map is on disk but index.css is gone" from "the
|
|
3618
|
+
// map itself never existed" (the Foundation was excluded), so the
|
|
3619
|
+
// guide can name the actual cause instead of always blaming the
|
|
3620
|
+
// Foundation.
|
|
3621
|
+
indexMissing: map !== null && imports === null,
|
|
3622
|
+
files
|
|
3623
|
+
};
|
|
3624
|
+
})
|
|
2825
3625
|
};
|
|
2826
3626
|
}
|
|
2827
3627
|
var code = (s) => `\`${s}\``;
|
|
@@ -2848,7 +3648,7 @@ function stackSection(input) {
|
|
|
2848
3648
|
""
|
|
2849
3649
|
);
|
|
2850
3650
|
} else {
|
|
2851
|
-
const label = platformSource === "flag" ? "chosen with --platform" : "detected";
|
|
3651
|
+
const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
|
|
2852
3652
|
lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
|
|
2853
3653
|
}
|
|
2854
3654
|
for (const platform of platforms) {
|
|
@@ -2856,10 +3656,47 @@ function stackSection(input) {
|
|
|
2856
3656
|
const tokensDir = `${input.outDir}/tokens/`;
|
|
2857
3657
|
if (platform === "web") {
|
|
2858
3658
|
lines.push("### Web", "");
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
3659
|
+
const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
|
|
3660
|
+
if (cssOut) {
|
|
3661
|
+
const mapPath = `${input.outDir}/outputs/web-css.map.json`;
|
|
3662
|
+
lines.push(
|
|
3663
|
+
`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). Each entry names the file that declares the property. Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
|
|
3664
|
+
""
|
|
3665
|
+
);
|
|
3666
|
+
const partFiles = cssOut.files.filter((f) => f !== CSS_INDEX_FILE);
|
|
3667
|
+
lines.push(
|
|
3668
|
+
`Import ${code(`${cssOut.path}/index.css`)} from the root stylesheet. It imports one file per collection and mode: ${partFiles.join(", ")}. Sets and default modes are at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)} in its own file, so a mode can also be imported alone. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). To let the OS choose, set that collection's selector to ${code(":root")} under ${code("outputs[].modes")} and import the mode's file yourself under ${code("@media (prefers-color-scheme: dark)")}; the CLI never assumes that.`,
|
|
3669
|
+
""
|
|
3670
|
+
);
|
|
3671
|
+
if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
|
|
3672
|
+
lines.push(
|
|
3673
|
+
`${code(`${cssOut.path}/`)} is a projection of the same ${code(tokensDir)} files, not a second source. Import one or the other.`,
|
|
3674
|
+
""
|
|
3675
|
+
);
|
|
3676
|
+
}
|
|
3677
|
+
} else {
|
|
3678
|
+
lines.push(
|
|
3679
|
+
`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.`,
|
|
3680
|
+
""
|
|
3681
|
+
);
|
|
3682
|
+
const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
|
|
3683
|
+
if (configuredNotWritten?.indexMissing) {
|
|
3684
|
+
lines.push(
|
|
3685
|
+
`A web/css output is configured at ${code(`${configuredNotWritten.path}/`)} but its ${code("index.css")} is missing, so the file list is unknown. Run ${code("npx spec-layer pull")} to write it again.`,
|
|
3686
|
+
""
|
|
3687
|
+
);
|
|
3688
|
+
} else if (configuredNotWritten) {
|
|
3689
|
+
lines.push(
|
|
3690
|
+
`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.`,
|
|
3691
|
+
""
|
|
3692
|
+
);
|
|
3693
|
+
} else if (pull?.foundation?.written) {
|
|
3694
|
+
lines.push(
|
|
3695
|
+
`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("tokens/")}.`,
|
|
3696
|
+
""
|
|
3697
|
+
);
|
|
3698
|
+
}
|
|
3699
|
+
}
|
|
2863
3700
|
if (profile.tokenTools.includes("tailwind")) {
|
|
2864
3701
|
lines.push(
|
|
2865
3702
|
`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.`,
|
|
@@ -2937,7 +3774,10 @@ function pullSection(input) {
|
|
|
2937
3774
|
} else {
|
|
2938
3775
|
lines.push("- This library has no Foundation, so there is no tokens/ directory.");
|
|
2939
3776
|
}
|
|
2940
|
-
lines.push(`- ${code(`${
|
|
3777
|
+
lines.push(`- ${code(`${pull.componentSpecsDir}/`)}: one YAML per component.`);
|
|
3778
|
+
for (const o of pull.outputs) {
|
|
3779
|
+
lines.push(o.written ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, ${o.case} names: ${o.files.join(", ")}. Non-default modes are under ${code(o.modeSelector)}, each in its own file. 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`)}.` : o.indexMissing ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, but ${code("index.css")} is missing; run ${code("npx spec-layer pull")} to restore the directory.` : `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
|
|
3780
|
+
}
|
|
2941
3781
|
lines.push("");
|
|
2942
3782
|
if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
|
|
2943
3783
|
lines.push("### Token collections", "");
|
|
@@ -2991,18 +3831,21 @@ function buildSkillGuide(input) {
|
|
|
2991
3831
|
`The Spec Layer Figma plugin publishes a design system's components, variables, and styles as data. The ${code("spec-layer")} CLI (version ${input.version}) pulls that data into this repository under ${code(outDir + "/")}. Everything in those files is extracted deterministically from Figma and validated against a published schema; no model wrote any of it. Treat it as the source of truth for what the design system contains, and treat anything it does not state as unknown rather than as something to infer.`,
|
|
2992
3832
|
""
|
|
2993
3833
|
);
|
|
3834
|
+
const componentSpecsDir = input.pull?.componentSpecsDir ?? input.config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
|
|
2994
3835
|
lines.push("## How to use it", "");
|
|
2995
3836
|
lines.push(`1. Run ${code("npx spec-layer status")}. Exit 0 means the local copy is current; exit 2 means run ${code("npx spec-layer pull")} first.`);
|
|
2996
|
-
lines.push(`2. Building or changing a component: read its YAML under ${code(`${
|
|
3837
|
+
lines.push(`2. Building or changing a component: read its YAML under ${code(`${componentSpecsDir}/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
|
|
2997
3838
|
lines.push(`3. Working with colors, spacing, type, or effects: start at ${code(`${outDir}/tokens/resolver.json`)}, load the set and mode files it names, and look up ${code("code_syntax")} in ${code("spec-layer.meta.json")} for the name the designer declared for your platform.`);
|
|
2998
3839
|
lines.push(`4. Reference tokens by name in code; never paste a resolved value where a token exists. A value the design system does not define is not a token: say so in your change rather than adding one.`);
|
|
2999
3840
|
lines.push(`5. An ${code("unbound")} entry is design debt reported from Figma. Do not silently promote it to a token; keep the literal and note that Figma has no binding for it.`);
|
|
3000
|
-
|
|
3841
|
+
const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
|
|
3842
|
+
const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(`${o.path}/`)).join(", ")} either: pull replaces or removes files there.` : "";
|
|
3843
|
+
lines.push(`6. Never edit files under ${code(outDir + "/")} or ${code(componentSpecsDir + "/")}: the next pull replaces or removes them.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
|
|
3001
3844
|
lines.push("");
|
|
3002
3845
|
lines.push(...pullSection(input));
|
|
3003
3846
|
lines.push(...stackSection(input));
|
|
3004
3847
|
lines.push(...commandsSection());
|
|
3005
|
-
lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
|
|
3848
|
+
lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components, after changing ${code("outputs")} or ${code("componentSpecsDir")}, or when the codebase changes stack; the file is replaced, not appended.`);
|
|
3006
3849
|
return `${lines.join("\n")}
|
|
3007
3850
|
`;
|
|
3008
3851
|
}
|
|
@@ -3072,25 +3915,25 @@ ${BLOCK_END}
|
|
|
3072
3915
|
const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
|
|
3073
3916
|
return `${existing.slice(0, begin)}${block}${after}`;
|
|
3074
3917
|
}
|
|
3075
|
-
const
|
|
3076
|
-
return `${existing}${
|
|
3918
|
+
const sep2 = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3919
|
+
return `${existing}${sep2}${block}`;
|
|
3077
3920
|
}
|
|
3078
3921
|
function installSkill(cwd, host, guide) {
|
|
3079
3922
|
const target = installTarget(host);
|
|
3080
|
-
const abs =
|
|
3081
|
-
const existing =
|
|
3923
|
+
const abs = join7(cwd, target.path);
|
|
3924
|
+
const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
|
|
3082
3925
|
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
3083
3926
|
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
3084
|
-
|
|
3085
|
-
|
|
3927
|
+
mkdirSync3(dirname3(abs), { recursive: true });
|
|
3928
|
+
writeFileSync6(abs, next);
|
|
3086
3929
|
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
3087
3930
|
}
|
|
3088
3931
|
|
|
3089
3932
|
// src/version.ts
|
|
3090
|
-
import { readFileSync as
|
|
3933
|
+
import { readFileSync as readFileSync8 } from "node:fs";
|
|
3091
3934
|
function cliVersion() {
|
|
3092
3935
|
try {
|
|
3093
|
-
const parsed = JSON.parse(
|
|
3936
|
+
const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
|
|
3094
3937
|
return typeof parsed.version === "string" ? parsed.version : "unknown";
|
|
3095
3938
|
} catch {
|
|
3096
3939
|
return "unknown";
|
|
@@ -3108,8 +3951,8 @@ function manifestReader() {
|
|
|
3108
3951
|
}
|
|
3109
3952
|
function sameOutput(a, b) {
|
|
3110
3953
|
const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
|
|
3111
|
-
const
|
|
3112
|
-
return selectionKey(a.selection) === selectionKey(b.selection) &&
|
|
3954
|
+
const key = (v) => JSON.stringify(sortKeys(v ?? {}));
|
|
3955
|
+
return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []) && (a.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR) === (b.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR);
|
|
3113
3956
|
}
|
|
3114
3957
|
function sortKeys(value) {
|
|
3115
3958
|
if (Array.isArray(value)) return value.map(sortKeys);
|
|
@@ -3118,6 +3961,35 @@ function sortKeys(value) {
|
|
|
3118
3961
|
}
|
|
3119
3962
|
return value;
|
|
3120
3963
|
}
|
|
3964
|
+
function platformsFromFlags(flags, io2) {
|
|
3965
|
+
if (flags.platform === void 0 || flags.platform.length === 0) return void 0;
|
|
3966
|
+
const out = [];
|
|
3967
|
+
for (const value of flags.platform) {
|
|
3968
|
+
if (!isPlatform(value)) {
|
|
3969
|
+
io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${value}".`);
|
|
3970
|
+
return null;
|
|
3971
|
+
}
|
|
3972
|
+
if (!out.includes(value)) out.push(value);
|
|
3973
|
+
}
|
|
3974
|
+
return out;
|
|
3975
|
+
}
|
|
3976
|
+
function resolvePlatforms(cwd, fromFlags, config) {
|
|
3977
|
+
if (fromFlags) return { platforms: fromFlags, source: "flag" };
|
|
3978
|
+
if (config?.platforms && config.platforms.length > 0) return { platforms: config.platforms, source: "config" };
|
|
3979
|
+
const detected = detectRepo(cwd).platforms;
|
|
3980
|
+
return { platforms: detected, source: detected.length > 0 ? "detected" : "none" };
|
|
3981
|
+
}
|
|
3982
|
+
function outputsForRun(fromFlags, config, platforms) {
|
|
3983
|
+
if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
|
|
3984
|
+
return config?.outputs ?? defaultOutputs(platforms);
|
|
3985
|
+
}
|
|
3986
|
+
var NO_PLATFORM_NOTE = `No target platform detected, so no token files were written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
|
|
3987
|
+
function platformsMissingFormat(platforms) {
|
|
3988
|
+
return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
|
|
3989
|
+
}
|
|
3990
|
+
function missingFormatNote(platforms) {
|
|
3991
|
+
return `No token files exist yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
|
|
3992
|
+
}
|
|
3121
3993
|
var errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
3122
3994
|
function runInit(cwd, flags, io2) {
|
|
3123
3995
|
if (!flags.id) {
|
|
@@ -3131,9 +4003,25 @@ function runInit(cwd, flags, io2) {
|
|
|
3131
4003
|
io2.err(errorText(err));
|
|
3132
4004
|
return 1;
|
|
3133
4005
|
}
|
|
4006
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4007
|
+
if (fromFlags === null) return 1;
|
|
4008
|
+
const { platforms, source } = resolvePlatforms(cwd, fromFlags, null);
|
|
4009
|
+
const outputs = defaultOutputs(platforms);
|
|
3134
4010
|
const outDir = flags.out ?? DEFAULT_OUT_DIR;
|
|
3135
|
-
writeConfig(cwd, {
|
|
3136
|
-
|
|
4011
|
+
writeConfig(cwd, {
|
|
4012
|
+
libraryId: flags.id,
|
|
4013
|
+
outDir,
|
|
4014
|
+
componentSpecsDir: DEFAULT_COMPONENT_SPECS_DIR,
|
|
4015
|
+
...include ? { include } : {},
|
|
4016
|
+
...platforms.length > 0 ? { platforms } : {},
|
|
4017
|
+
...outputs.length > 0 ? { outputs } : {}
|
|
4018
|
+
});
|
|
4019
|
+
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
|
|
4020
|
+
for (const o of outputs) io2.out(`Token files for ${o.platform}: ${o.path}/ (${o.format}, ${o.case} names), written by the next pull.`);
|
|
4021
|
+
if (source === "flag" || source === "detected") {
|
|
4022
|
+
const missing = platformsMissingFormat(platforms);
|
|
4023
|
+
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
4024
|
+
}
|
|
3137
4025
|
io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
|
|
3138
4026
|
return 0;
|
|
3139
4027
|
}
|
|
@@ -3157,7 +4045,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
|
|
|
3157
4045
|
}
|
|
3158
4046
|
function resolvedOutDir(cwd, flags, io2) {
|
|
3159
4047
|
try {
|
|
3160
|
-
return
|
|
4048
|
+
return join8(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
|
|
3161
4049
|
} catch (err) {
|
|
3162
4050
|
io2.err(errorText(err));
|
|
3163
4051
|
return null;
|
|
@@ -3193,16 +4081,24 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
|
|
|
3193
4081
|
} catch {
|
|
3194
4082
|
existing = null;
|
|
3195
4083
|
}
|
|
4084
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4085
|
+
if (fromFlags === null) return 1;
|
|
3196
4086
|
const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
|
|
4087
|
+
const componentSpecsDir = existing?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
|
|
3197
4088
|
const keptInclude = include ?? existing?.include ?? null;
|
|
3198
4089
|
const keptDtcg = existing?.dtcg ?? null;
|
|
4090
|
+
const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
|
|
4091
|
+
const outputs = withDefaults(existing?.outputs ?? [], platforms);
|
|
3199
4092
|
writeConfig(cwd, {
|
|
3200
4093
|
libraryId: flags.id,
|
|
3201
4094
|
outDir,
|
|
4095
|
+
componentSpecsDir,
|
|
3202
4096
|
...keptInclude ? { include: keptInclude } : {},
|
|
3203
|
-
...keptDtcg ? { dtcg: keptDtcg } : {}
|
|
4097
|
+
...keptDtcg ? { dtcg: keptDtcg } : {},
|
|
4098
|
+
...platforms.length > 0 ? { platforms } : {},
|
|
4099
|
+
...existing?.outputs !== void 0 || outputs.length > 0 ? { outputs } : {}
|
|
3204
4100
|
});
|
|
3205
|
-
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
|
|
4101
|
+
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
|
|
3206
4102
|
const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
|
|
3207
4103
|
switch (ignored.kind) {
|
|
3208
4104
|
case "refused":
|
|
@@ -3249,6 +4145,12 @@ git rm --cached ${ignored.line}`);
|
|
|
3249
4145
|
io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
|
|
3250
4146
|
return 0;
|
|
3251
4147
|
}
|
|
4148
|
+
function outputFilesOnDisk(cwd, outDir, o) {
|
|
4149
|
+
const mapPath = join8(cwd, outDir, "outputs", `${outputId(o)}.map.json`);
|
|
4150
|
+
if (!existsSync8(mapPath)) return false;
|
|
4151
|
+
const imports = readIndexImports(cwd, o);
|
|
4152
|
+
return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
|
|
4153
|
+
}
|
|
3252
4154
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
3253
4155
|
const manifestAt = manifestReader();
|
|
3254
4156
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
@@ -3260,11 +4162,18 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
3260
4162
|
io2.err(errorText(err));
|
|
3261
4163
|
return 1;
|
|
3262
4164
|
}
|
|
3263
|
-
const
|
|
4165
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4166
|
+
if (fromFlags === null) return 1;
|
|
4167
|
+
const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
|
|
4168
|
+
const outputs = outputsForRun(fromFlags, opts, platforms);
|
|
4169
|
+
const manifest = manifestAt(join8(cwd, opts.outDir));
|
|
4170
|
+
const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
|
|
4171
|
+
const willWriteFoundation = selection.foundation && foundationOnDisk;
|
|
4172
|
+
const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
|
|
3264
4173
|
const etag = manifest && sameOutput(
|
|
3265
|
-
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
|
|
3266
|
-
{ selection, dtcg: opts.dtcg }
|
|
3267
|
-
) ? manifest.bundleHash : void 0;
|
|
4174
|
+
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
|
|
4175
|
+
{ selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
|
|
4176
|
+
) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
|
|
3268
4177
|
const result = await fetchBundle({
|
|
3269
4178
|
api: opts.api,
|
|
3270
4179
|
libraryId: opts.libraryId,
|
|
@@ -3281,11 +4190,13 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
3281
4190
|
return 0;
|
|
3282
4191
|
}
|
|
3283
4192
|
let written;
|
|
4193
|
+
let componentSpecs;
|
|
4194
|
+
let outputResults;
|
|
3284
4195
|
try {
|
|
3285
4196
|
const bundle = parseBundle(result.raw);
|
|
3286
4197
|
const selected = selectComponents(bundle, selection);
|
|
3287
|
-
|
|
3288
|
-
outDir:
|
|
4198
|
+
const writeResult = writeBundleFiles({
|
|
4199
|
+
outDir: join8(cwd, opts.outDir),
|
|
3289
4200
|
cwd,
|
|
3290
4201
|
raw: result.raw,
|
|
3291
4202
|
bundle,
|
|
@@ -3293,8 +4204,14 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
3293
4204
|
libraryId: opts.libraryId,
|
|
3294
4205
|
publishedAt: result.publishedAt,
|
|
3295
4206
|
bundleHash: result.bundleHash,
|
|
3296
|
-
dtcg: opts.dtcg
|
|
4207
|
+
dtcg: opts.dtcg,
|
|
4208
|
+
platforms,
|
|
4209
|
+
outputs,
|
|
4210
|
+
componentSpecsDir: opts.componentSpecsDir
|
|
3297
4211
|
});
|
|
4212
|
+
written = writeResult.written;
|
|
4213
|
+
componentSpecs = writeResult.componentSpecs;
|
|
4214
|
+
outputResults = writeResult.outputs;
|
|
3298
4215
|
io2.out(
|
|
3299
4216
|
`Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
|
|
3300
4217
|
);
|
|
@@ -3302,14 +4219,35 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
3302
4219
|
io2.err(errorText(err));
|
|
3303
4220
|
return 1;
|
|
3304
4221
|
}
|
|
4222
|
+
const count = (n) => `${n} file${n === 1 ? "" : "s"}`;
|
|
3305
4223
|
io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
|
|
4224
|
+
if (componentSpecs.files.length > 0) io2.out(`Wrote ${componentSpecs.path}/ (${count(componentSpecs.files.length)}).`);
|
|
4225
|
+
for (const r of outputResults) {
|
|
4226
|
+
const o = outputs.find((x) => x.path === r.path);
|
|
4227
|
+
if (o) io2.out(`Wrote ${r.path}/ (${count(r.files.length)}, ${o.platform}/${o.format}, ${o.case} names).`);
|
|
4228
|
+
}
|
|
4229
|
+
const staleDirNote = (previous, current) => {
|
|
4230
|
+
if (previous !== current && existsSync8(resolve5(cwd, previous))) {
|
|
4231
|
+
io2.out(`The previous pull wrote ${previous}/; this one wrote ${current}/. Delete ${previous}/ if nothing else uses it.`);
|
|
4232
|
+
}
|
|
4233
|
+
};
|
|
4234
|
+
if (manifest) staleDirNote(manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR, opts.componentSpecsDir);
|
|
4235
|
+
for (const prev of manifest?.outputs ?? []) {
|
|
4236
|
+
const current = outputs.find((o) => outputId(o) === outputId(prev));
|
|
4237
|
+
if (current) staleDirNote(prev.path, current.path);
|
|
4238
|
+
}
|
|
4239
|
+
if (outputResults.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
|
|
4240
|
+
if (source === "flag" || source === "config") {
|
|
4241
|
+
const missing = platformsMissingFormat(platforms);
|
|
4242
|
+
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
4243
|
+
}
|
|
3306
4244
|
return 0;
|
|
3307
4245
|
}
|
|
3308
4246
|
async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
3309
4247
|
const manifestAt = manifestReader();
|
|
3310
4248
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
3311
4249
|
if (!opts) return 1;
|
|
3312
|
-
const manifest = manifestAt(
|
|
4250
|
+
const manifest = manifestAt(join8(cwd, opts.outDir));
|
|
3313
4251
|
if (!manifest) {
|
|
3314
4252
|
io2.err(NO_LOCAL_PULL);
|
|
3315
4253
|
return 2;
|
|
@@ -3341,11 +4279,15 @@ function runList(cwd, flags, io2) {
|
|
|
3341
4279
|
return 1;
|
|
3342
4280
|
}
|
|
3343
4281
|
io2.out(`Library ${manifest.libraryId}, published ${manifest.publishedAt}.`);
|
|
3344
|
-
const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.
|
|
4282
|
+
const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.path ?? "not written", a.contentHash]);
|
|
3345
4283
|
const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
|
|
3346
4284
|
for (const row of rows) {
|
|
3347
4285
|
io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
|
|
3348
4286
|
}
|
|
4287
|
+
for (const o of manifest.outputs ?? []) {
|
|
4288
|
+
const written = existsSync8(join8(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
|
|
4289
|
+
io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
|
|
4290
|
+
}
|
|
3349
4291
|
return 0;
|
|
3350
4292
|
}
|
|
3351
4293
|
var SHOW_USAGE = 'spec-layer show takes "foundation" or "component NAME".';
|
|
@@ -3410,20 +4352,10 @@ function collectSkillInput(cwd, flags, io2) {
|
|
|
3410
4352
|
}
|
|
3411
4353
|
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
3412
4354
|
const profile = detectRepo(cwd);
|
|
3413
|
-
|
|
3414
|
-
|
|
3415
|
-
|
|
3416
|
-
|
|
3417
|
-
io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${flags.platform}".`);
|
|
3418
|
-
return null;
|
|
3419
|
-
}
|
|
3420
|
-
platforms = [flags.platform];
|
|
3421
|
-
platformSource = "flag";
|
|
3422
|
-
} else {
|
|
3423
|
-
platforms = profile.platforms;
|
|
3424
|
-
platformSource = platforms.length > 0 ? "detected" : "none";
|
|
3425
|
-
}
|
|
3426
|
-
const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
|
|
4355
|
+
const fromFlags = platformsFromFlags(flags, io2);
|
|
4356
|
+
if (fromFlags === null) return null;
|
|
4357
|
+
const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
|
|
4358
|
+
const pull = summarizePull(cwd, outDir, readManifest(join8(cwd, outDir)));
|
|
3427
4359
|
return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
|
|
3428
4360
|
}
|
|
3429
4361
|
function skillHosts(flags, input, io2) {
|
|
@@ -3484,17 +4416,18 @@ function runSkill(cwd, flags, io2) {
|
|
|
3484
4416
|
var USAGE = `spec-layer <command>
|
|
3485
4417
|
|
|
3486
4418
|
Commands:
|
|
3487
|
-
setup --id lib_... --key sl_... [--out DIR] [selection]
|
|
4419
|
+
setup --id lib_... --key sl_... [--out DIR] [selection] [--platform P]...
|
|
3488
4420
|
store the key, then pull
|
|
3489
|
-
init --id lib_... [--out DIR] [selection]
|
|
3490
|
-
|
|
4421
|
+
init --id lib_... [--out DIR] [selection] [--platform P]...
|
|
4422
|
+
write speclayer.json
|
|
4423
|
+
pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
|
|
3491
4424
|
fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
|
|
3492
4425
|
status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
|
|
3493
4426
|
list list every artifact in the last pull
|
|
3494
4427
|
show foundation | component NAME [--canonical]
|
|
3495
4428
|
print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
|
|
3496
4429
|
tools [--json] list every command with what it reaches and writes
|
|
3497
|
-
skill [--install] [--agent HOST]... [--platform P] [--json]
|
|
4430
|
+
skill [--install] [--agent HOST]... [--platform P]... [--json]
|
|
3498
4431
|
print a guide for a coding agent, adapted to this repo and the last pull;
|
|
3499
4432
|
--install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
|
|
3500
4433
|
|
|
@@ -3504,6 +4437,7 @@ Selection (setup, pull and init; flags replace the include block in speclayer.js
|
|
|
3504
4437
|
|
|
3505
4438
|
Options:
|
|
3506
4439
|
--api URL override the API origin (default https://api.spec-layer.com)
|
|
4440
|
+
--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
|
|
3507
4441
|
The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
|
|
3508
4442
|
var io = {
|
|
3509
4443
|
out: (l) => console.log(l),
|
|
@@ -3529,7 +4463,7 @@ async function main() {
|
|
|
3529
4463
|
json: { type: "boolean" },
|
|
3530
4464
|
install: { type: "boolean" },
|
|
3531
4465
|
agent: { type: "string", multiple: true },
|
|
3532
|
-
platform: { type: "string" }
|
|
4466
|
+
platform: { type: "string", multiple: true }
|
|
3533
4467
|
}
|
|
3534
4468
|
}));
|
|
3535
4469
|
} catch {
|