spec-layer 0.6.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 +80 -42
- package/dist/cli.js +321 -140
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -60,7 +60,9 @@ against them. The Foundation landing under `tokens/` as Design Tokens Format
|
|
|
60
60
|
Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
|
|
61
61
|
`tools` and `skill` need 0.5.0 or later. `components/` in place of
|
|
62
62
|
`ai/components/`, `path` in place of `aiPath` in the manifest, and the
|
|
63
|
-
`outputs` block need 0.6.0 or later.
|
|
63
|
+
`outputs` block need 0.6.0 or later. `component-specs/` beside `tokens/`,
|
|
64
|
+
`componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
|
|
65
|
+
need 0.7.0 or later.
|
|
64
66
|
|
|
65
67
|
## Commands
|
|
66
68
|
|
|
@@ -146,7 +148,7 @@ carry no key and are meant to be committed with the rest of the repository.
|
|
|
146
148
|
## Pulling part of a library
|
|
147
149
|
|
|
148
150
|
By default `pull` writes the Foundation and every documented component. When
|
|
149
|
-
your repo implements only some of them, narrow what lands in `
|
|
151
|
+
your repo implements only some of them, narrow what lands in `component-specs/`:
|
|
150
152
|
|
|
151
153
|
```bash
|
|
152
154
|
npx spec-layer pull --only foundation # tokens and styles, no components
|
|
@@ -177,7 +179,7 @@ That stores an `include` block in `speclayer.json`:
|
|
|
177
179
|
Selection flags on `pull` replace the stored selection for that run; they are
|
|
178
180
|
never merged with it.
|
|
179
181
|
|
|
180
|
-
The selection only decides which `
|
|
182
|
+
The selection only decides which `component-specs/` files are written. `bundle.json`
|
|
181
183
|
always holds the whole library, so `list` and `show` can answer for any
|
|
182
184
|
artifact, written or not, and `status` compares one hash.
|
|
183
185
|
|
|
@@ -244,7 +246,7 @@ matter what the ignore rules say.
|
|
|
244
246
|
```text
|
|
245
247
|
.speclayer/
|
|
246
248
|
bundle.json the published bundle, verbatim
|
|
247
|
-
manifest.json
|
|
249
|
+
manifest.json every artifact indexed by content hash and path, plus the selection, outputs, and componentSpecsDir
|
|
248
250
|
tokens/ the Foundation as Design Tokens Format Module 2025.10 files
|
|
249
251
|
<collection>.<mode>.json one file per collection and mode, rooted at the collection name
|
|
250
252
|
styles.typography.json text styles as typography composites (when present)
|
|
@@ -252,19 +254,22 @@ matter what the ignore rules say.
|
|
|
252
254
|
resolver.json Design Tokens Resolver Module 2025.10: sets, modifiers, order
|
|
253
255
|
spec-layer.meta.json Figma ids, scopes, code syntax, publication, keyed by DTCG path
|
|
254
256
|
report.json what DTCG could not express, with reasons and stable ids
|
|
255
|
-
components/<name>.yaml one file per selected component
|
|
256
257
|
outputs/
|
|
257
|
-
web-css.map.json DTCG path -> CSS custom property,
|
|
258
|
-
web-css.report.json what the CSS
|
|
259
|
-
|
|
260
|
-
|
|
258
|
+
web-css.map.json DTCG path -> CSS custom property, where the name came from, and which file declares it
|
|
259
|
+
web-css.report.json what the CSS files could not express
|
|
260
|
+
component-specs/
|
|
261
|
+
<name>.yaml one brief per selected component, byte-identical to Copy for AI
|
|
262
|
+
tokens/
|
|
263
|
+
index.css imports every file below, in resolver order
|
|
264
|
+
<collection>.css one file per single-mode collection, at :root
|
|
265
|
+
<collection>.<mode>.css one file per mode of a multi-mode collection; the default at :root
|
|
261
266
|
```
|
|
262
267
|
|
|
263
|
-
Point your agent at
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
+
Point your agent at `component-specs/` and `.speclayer/tokens/`, and import
|
|
269
|
+
`tokens/index.css` from the platform's root stylesheet. The component YAML is
|
|
270
|
+
the same compact form the plugin's **Copy for AI** puts on your clipboard;
|
|
271
|
+
`bundle.json` additionally holds the full canonical artifacts if you need
|
|
272
|
+
them.
|
|
268
273
|
|
|
269
274
|
In `manifest.json`, an artifact the selection left unwritten has `"path":
|
|
270
275
|
null`. A manifest from CLI 0.1.0 has no `selection` field and means
|
|
@@ -275,12 +280,19 @@ interrupted pull never leaves a half-written directory. `pull` refuses an
|
|
|
275
280
|
output directory that is the current directory, a parent of it, or an existing
|
|
276
281
|
non-empty directory it did not write, since the swap replaces that directory.
|
|
277
282
|
|
|
283
|
+
`component-specs/` and `tokens/` are written in place, not swapped. `pull`
|
|
284
|
+
owns exactly the files there that begin with its marker (the CSS header, or
|
|
285
|
+
the brief's opening `spec_layer:` lines): it replaces or removes those,
|
|
286
|
+
ignores dotfiles, and refuses to run when anything else is present. A
|
|
287
|
+
repository that already uses a path can set `componentSpecsDir` or
|
|
288
|
+
`outputs[].path`.
|
|
289
|
+
|
|
278
290
|
When nothing changed since the last pull with the same selection, `pull`
|
|
279
291
|
prints `Already up to date` and writes nothing. Every republish stamps a new
|
|
280
292
|
export id and time into the canonical artifacts, so `bundle.json` and
|
|
281
293
|
`manifest.json` change on each republish even when the content did not. The
|
|
282
|
-
`
|
|
283
|
-
stable.
|
|
294
|
+
`component-specs/` YAML files, the `.speclayer/tokens/` files, and the
|
|
295
|
+
`tokens/` CSS stay stable.
|
|
284
296
|
|
|
285
297
|
## Configuring the token output
|
|
286
298
|
|
|
@@ -309,19 +321,26 @@ the report are not token files; exclude them from token globs.
|
|
|
309
321
|
|
|
310
322
|
## Token files for your code
|
|
311
323
|
|
|
312
|
-
`pull` also writes a
|
|
313
|
-
|
|
314
|
-
when that pull writes the Foundation; with
|
|
315
|
-
exactly as it was, and `list` shows it as
|
|
324
|
+
`pull` also writes a `tokens/` directory at `outputs[].path`: one CSS file
|
|
325
|
+
per collection and mode plus `index.css`, one per platform output. The
|
|
326
|
+
directory is written only when that pull writes the Foundation; with
|
|
327
|
+
`--only components` it is left exactly as it was, and `list` shows it as
|
|
328
|
+
`not written`.
|
|
316
329
|
|
|
317
330
|
```css
|
|
331
|
+
/* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
|
|
332
|
+
Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
|
|
333
|
+
|
|
318
334
|
:root {
|
|
319
335
|
/* Foundation */
|
|
320
336
|
--foundation-colors-blue-500: #2e72d1;
|
|
321
337
|
--foundation-spacing-200: 8px;
|
|
322
|
-
/* Mapped Colors, Light */
|
|
323
|
-
--mapped-colors-surface-primary-default: var(--foundation-colors-blue-500);
|
|
324
338
|
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
```css
|
|
342
|
+
/* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
|
|
343
|
+
Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
|
|
325
344
|
|
|
326
345
|
[data-theme="dark"] {
|
|
327
346
|
/* Mapped Colors, Dark */
|
|
@@ -329,23 +348,42 @@ exactly as it was, and `list` shows it as `not written`.
|
|
|
329
348
|
}
|
|
330
349
|
```
|
|
331
350
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
351
|
+
`index.css` holds the header, then one comment and one import per file, in
|
|
352
|
+
resolver order, and no declarations of its own:
|
|
353
|
+
|
|
354
|
+
```css
|
|
355
|
+
/* Generated by spec-layer from library lib_..., foundation sha256:..., web/css/kebab.
|
|
356
|
+
Do not edit. Change the design in Figma, republish, and run spec-layer pull. */
|
|
357
|
+
|
|
358
|
+
/* Foundation */
|
|
359
|
+
@import "./foundation.css";
|
|
360
|
+
/* Mapped Colors, Dark */
|
|
361
|
+
@import "./mapped-colors.dark.css";
|
|
362
|
+
```
|
|
337
363
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
364
|
+
A set, which has one mode by construction, writes one file named for the
|
|
365
|
+
collection alone, at `:root`. A collection with modes writes one file per
|
|
366
|
+
mode: the default mode's file sits at `:root`, every other mode's file under
|
|
367
|
+
`[data-theme="<mode>"]`. Aliases stay as `var()`. A number whose Figma scopes
|
|
368
|
+
state no unit stays a bare number. Nothing about a mode's name selects a
|
|
369
|
+
media query. To let the OS choose a theme, set that collection's selector to
|
|
370
|
+
`:root` under `modes` and import the mode's file yourself under `@media
|
|
371
|
+
(prefers-color-scheme: dark)`. The CLI never assumes that.
|
|
372
|
+
|
|
373
|
+
The directory is written **in place** at the path in `speclayer.json`,
|
|
374
|
+
outside the managed directory, so your bundler keeps watching it and the diff
|
|
375
|
+
shows up in review. Every file begins with a header naming the library and
|
|
376
|
+
the Foundation's content hash; `pull` owns exactly the files there that begin
|
|
377
|
+
with it, replacing or removing those, ignoring dotfiles, and refusing to run
|
|
378
|
+
when anything else is present. A `speclayer.json` written by CLI 0.6.0 names
|
|
379
|
+
a `.css` file at this path, and `pull` refuses it: set `path` to a directory
|
|
380
|
+
and delete the old file.
|
|
343
381
|
|
|
344
382
|
```json
|
|
345
383
|
{
|
|
346
384
|
"platforms": ["web"],
|
|
347
385
|
"outputs": [
|
|
348
|
-
{ "platform": "web", "format": "css", "path": "
|
|
386
|
+
{ "platform": "web", "format": "css", "path": "tokens", "case": "kebab" }
|
|
349
387
|
]
|
|
350
388
|
}
|
|
351
389
|
```
|
|
@@ -355,28 +393,28 @@ at the repository root, so the path is always on record. `pull` writes every
|
|
|
355
393
|
entry; `"outputs": []` writes none. `case` chooses how derived names are
|
|
356
394
|
spelled: `kebab` (default), `camel`, `pascal`, `snake`, or `constant`. A name
|
|
357
395
|
the designer declared as `code_syntax` in Figma is used verbatim and never
|
|
358
|
-
re-cased. `.speclayer/outputs/web-css.map.json` records every emitted name
|
|
359
|
-
whether it was declared or derived
|
|
360
|
-
both omitted and listed in `web-css.report.json`.
|
|
396
|
+
re-cased. `.speclayer/outputs/web-css.map.json` records every emitted name,
|
|
397
|
+
whether it was declared or derived, and which file declares it; two tokens
|
|
398
|
+
that would share a name are both omitted and listed in `web-css.report.json`.
|
|
361
399
|
|
|
362
400
|
Two collections with modes share one attribute by default, which cannot be
|
|
363
401
|
right for both; the report says so, and `modes` declares a selector per
|
|
364
402
|
collection:
|
|
365
403
|
|
|
366
404
|
```json
|
|
367
|
-
{ "platform": "web", "format": "css", "path": "
|
|
405
|
+
{ "platform": "web", "format": "css", "path": "tokens",
|
|
368
406
|
"modes": { "Density": "[data-density=\"{mode}\"]" } }
|
|
369
407
|
```
|
|
370
408
|
|
|
371
409
|
`root` and `modeSelector` override the defaults `:root` and
|
|
372
410
|
`[data-theme="{mode}"]`; `{mode}` and `{collection}` are replaced by slugs.
|
|
373
411
|
A repository that already builds tokens with Style Dictionary can keep reading
|
|
374
|
-
|
|
375
|
-
source, so import one or the other.
|
|
412
|
+
`.speclayer/tokens/`: the CSS files are a projection of the same record, not a
|
|
413
|
+
second source, so import one or the other.
|
|
376
414
|
|
|
377
|
-
Commit `.speclayer/`, `speclayer.json`, and the output
|
|
378
|
-
that would rather regenerate in CI ignores them and runs
|
|
379
|
-
`status` exits `2` when a pull is due.
|
|
415
|
+
Commit `.speclayer/`, `speclayer.json`, `component-specs/`, and the output
|
|
416
|
+
paths. A repository that would rather regenerate in CI ignores them and runs
|
|
417
|
+
`pull` there; `status` exits `2` when a pull is due.
|
|
380
418
|
|
|
381
419
|
## Exit codes
|
|
382
420
|
|
package/dist/cli.js
CHANGED
|
@@ -560,7 +560,7 @@ import { parseArgs } from "node:util";
|
|
|
560
560
|
|
|
561
561
|
// src/commands.ts
|
|
562
562
|
import { existsSync as existsSync8 } from "node:fs";
|
|
563
|
-
import { join as
|
|
563
|
+
import { join as join8, resolve as resolve5 } from "node:path";
|
|
564
564
|
|
|
565
565
|
// ../extractor/src/statesMatrix.ts
|
|
566
566
|
var STATE_ORDER = [
|
|
@@ -1334,7 +1334,15 @@ function dtcgSegments(name) {
|
|
|
1334
1334
|
}
|
|
1335
1335
|
return { segments, notes };
|
|
1336
1336
|
}
|
|
1337
|
-
|
|
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;
|
|
1338
1346
|
var RESERVED_FILE_NAMES = [
|
|
1339
1347
|
"styles.typography.json",
|
|
1340
1348
|
"styles.effects.json",
|
|
@@ -2121,6 +2129,7 @@ function resolveNames(paths, meta, rules) {
|
|
|
2121
2129
|
}
|
|
2122
2130
|
|
|
2123
2131
|
// ../extractor/src/v5/outputs/css.ts
|
|
2132
|
+
var CSS_INDEX_FILE = "index.css";
|
|
2124
2133
|
var CSS_DEFAULTS = {
|
|
2125
2134
|
case: "kebab",
|
|
2126
2135
|
root: ":root",
|
|
@@ -2186,6 +2195,23 @@ function sourcesOf(resolver) {
|
|
|
2186
2195
|
}
|
|
2187
2196
|
var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
|
|
2188
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
|
+
}
|
|
2189
2215
|
var REF = /^\{(.+)\}$/;
|
|
2190
2216
|
function hexChannels(hex) {
|
|
2191
2217
|
const at = (i) => parseInt(hex.slice(i, i + 2), 16);
|
|
@@ -2394,17 +2420,22 @@ function headerText(header, nameCase) {
|
|
|
2394
2420
|
function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
2395
2421
|
const entries = [];
|
|
2396
2422
|
const declared = /* @__PURE__ */ new Set();
|
|
2423
|
+
const firstFile = /* @__PURE__ */ new Map();
|
|
2397
2424
|
const blocks = /* @__PURE__ */ new Map();
|
|
2398
2425
|
for (const s of sources) {
|
|
2399
2426
|
const perCollection = modes?.[s.collection];
|
|
2400
2427
|
const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
|
|
2401
2428
|
const decls = [];
|
|
2429
|
+
const declaredHere = [];
|
|
2402
2430
|
for (const leaf of leavesByFile.get(s.file) ?? []) {
|
|
2403
2431
|
const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
|
|
2404
2432
|
if (leaf.type === "typography") {
|
|
2405
2433
|
const t = typographyDecls(ctx, leaf, names);
|
|
2406
2434
|
decls.push(...t.decls);
|
|
2407
|
-
for (const p of t.declaredPaths)
|
|
2435
|
+
for (const p of t.declaredPaths) {
|
|
2436
|
+
declared.add(p);
|
|
2437
|
+
declaredHere.push(p);
|
|
2438
|
+
}
|
|
2408
2439
|
} else {
|
|
2409
2440
|
const name = names.get(leaf.path);
|
|
2410
2441
|
if (name === void 0) continue;
|
|
@@ -2413,22 +2444,26 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
|
|
|
2413
2444
|
if (d !== null) {
|
|
2414
2445
|
decls.push(d);
|
|
2415
2446
|
declared.add(leaf.path);
|
|
2447
|
+
declaredHere.push(leaf.path);
|
|
2416
2448
|
}
|
|
2417
2449
|
} else {
|
|
2418
2450
|
const v = cssValue(ctx, leaf.type, leaf.value);
|
|
2419
2451
|
if (v !== null) {
|
|
2420
2452
|
decls.push(`${name}: ${v};`);
|
|
2421
2453
|
declared.add(leaf.path);
|
|
2454
|
+
declaredHere.push(leaf.path);
|
|
2422
2455
|
}
|
|
2423
2456
|
}
|
|
2424
2457
|
}
|
|
2425
2458
|
}
|
|
2426
2459
|
if (decls.length === 0) continue;
|
|
2427
|
-
const
|
|
2428
|
-
|
|
2429
|
-
blocks.
|
|
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 });
|
|
2430
2465
|
}
|
|
2431
|
-
return { blocks, entries, declared };
|
|
2466
|
+
return { blocks, entries, declared, firstFile };
|
|
2432
2467
|
}
|
|
2433
2468
|
function cssOutput(exp, header, options = {}) {
|
|
2434
2469
|
const nameCase = options.case ?? CSS_DEFAULTS.case;
|
|
@@ -2458,7 +2493,7 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2458
2493
|
const resolved2 = resolveNames([...candidatePaths], exp.meta, {
|
|
2459
2494
|
codeSyntaxKey: "WEB",
|
|
2460
2495
|
acceptDeclared: acceptCssDeclared,
|
|
2461
|
-
affix: (
|
|
2496
|
+
affix: (body) => `--${body}`,
|
|
2462
2497
|
nameCase
|
|
2463
2498
|
});
|
|
2464
2499
|
const names = resolved2.names;
|
|
@@ -2470,9 +2505,11 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2470
2505
|
alive = pass.declared;
|
|
2471
2506
|
pass = emit(alive);
|
|
2472
2507
|
}
|
|
2508
|
+
const fileNames = cssFileNames(sources);
|
|
2473
2509
|
const map = {};
|
|
2474
2510
|
for (const [path, entry2] of Object.entries(resolved2.map)) {
|
|
2475
|
-
|
|
2511
|
+
const from = pass.firstFile.get(path);
|
|
2512
|
+
if (alive.has(path) && from !== void 0) map[path] = { ...entry2, file: fileNames.get(from) };
|
|
2476
2513
|
}
|
|
2477
2514
|
const entries = [...resolved2.report, ...pass.entries];
|
|
2478
2515
|
const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
|
|
@@ -2487,10 +2524,25 @@ function cssOutput(exp, header, options = {}) {
|
|
|
2487
2524
|
});
|
|
2488
2525
|
}
|
|
2489
2526
|
}
|
|
2490
|
-
const
|
|
2491
|
-
const
|
|
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")}
|
|
2492
2544
|
`;
|
|
2493
|
-
return {
|
|
2545
|
+
return { files, map, report: sortReport(entries) };
|
|
2494
2546
|
}
|
|
2495
2547
|
|
|
2496
2548
|
// ../extractor/src/v5/componentContext.ts
|
|
@@ -2585,7 +2637,7 @@ function parseBundle(raw) {
|
|
|
2585
2637
|
|
|
2586
2638
|
// src/config.ts
|
|
2587
2639
|
import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
|
|
2588
|
-
import { join as
|
|
2640
|
+
import { join as join4 } from "node:path";
|
|
2589
2641
|
|
|
2590
2642
|
// src/credentials.ts
|
|
2591
2643
|
import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
|
|
@@ -2768,17 +2820,93 @@ function isAgentHost(value) {
|
|
|
2768
2820
|
}
|
|
2769
2821
|
|
|
2770
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
|
|
2771
2827
|
import {
|
|
2828
|
+
closeSync,
|
|
2772
2829
|
existsSync as existsSync3,
|
|
2830
|
+
lstatSync,
|
|
2773
2831
|
mkdirSync,
|
|
2774
|
-
|
|
2832
|
+
openSync,
|
|
2833
|
+
readSync,
|
|
2834
|
+
readdirSync as readdirSync2,
|
|
2775
2835
|
renameSync,
|
|
2776
2836
|
rmSync,
|
|
2777
2837
|
writeFileSync as writeFileSync2
|
|
2778
2838
|
} from "node:fs";
|
|
2779
|
-
import {
|
|
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
|
|
2780
2908
|
var FORMATS = [
|
|
2781
|
-
{ platform: "web", format: "css", defaultPath: "
|
|
2909
|
+
{ platform: "web", format: "css", defaultPath: "tokens", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
|
|
2782
2910
|
];
|
|
2783
2911
|
var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
|
|
2784
2912
|
var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
|
|
@@ -2844,45 +2972,27 @@ function renderOutput(exp, o, header) {
|
|
|
2844
2972
|
}
|
|
2845
2973
|
}
|
|
2846
2974
|
}
|
|
2847
|
-
|
|
2848
|
-
const
|
|
2849
|
-
|
|
2850
|
-
};
|
|
2851
|
-
function outputPathProblem(cwd, outDir, o) {
|
|
2852
|
-
const root = resolve(cwd);
|
|
2853
|
-
const abs = resolve(cwd, o.path);
|
|
2854
|
-
if (!inside(root, abs) || abs === root) return `${o.path} is outside this directory. Choose a path inside the repository.`;
|
|
2855
|
-
if (inside(resolve(cwd, outDir), abs)) {
|
|
2856
|
-
return `${o.path} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
|
|
2857
|
-
}
|
|
2858
|
-
if (existsSync3(abs)) {
|
|
2859
|
-
const prefix = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
|
|
2860
|
-
let head;
|
|
2861
|
-
try {
|
|
2862
|
-
head = readFileSync3(abs, "utf8").slice(0, prefix.length);
|
|
2863
|
-
} catch {
|
|
2864
|
-
return `${o.path} exists and could not be read.`;
|
|
2865
|
-
}
|
|
2866
|
-
if (head !== prefix) return `${o.path} exists and was not written by spec-layer. Choose another path or remove the file.`;
|
|
2867
|
-
}
|
|
2868
|
-
return null;
|
|
2869
|
-
}
|
|
2870
|
-
function writeOutputFile(cwd, o, text) {
|
|
2871
|
-
const abs = resolve(cwd, o.path);
|
|
2872
|
-
mkdirSync(dirname(abs), { recursive: true });
|
|
2873
|
-
const partial = `${abs}.partial`;
|
|
2874
|
-
writeFileSync2(partial, text);
|
|
2975
|
+
function readIndexImports(cwd, o) {
|
|
2976
|
+
const path = resolve2(cwd, o.path, CSS_INDEX_FILE);
|
|
2977
|
+
let text;
|
|
2875
2978
|
try {
|
|
2876
|
-
|
|
2877
|
-
} catch
|
|
2878
|
-
|
|
2879
|
-
throw err;
|
|
2979
|
+
text = readFileSync3(path, "utf8");
|
|
2980
|
+
} catch {
|
|
2981
|
+
return null;
|
|
2880
2982
|
}
|
|
2983
|
+
return [...text.matchAll(/^@import "\.\/([^"\n]+)";$/gm)].map((m) => m[1]);
|
|
2984
|
+
}
|
|
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");
|
|
2881
2990
|
}
|
|
2882
2991
|
|
|
2883
2992
|
// src/config.ts
|
|
2884
2993
|
var DEFAULT_API = "https://api.spec-layer.com";
|
|
2885
2994
|
var DEFAULT_OUT_DIR = ".speclayer";
|
|
2995
|
+
var DEFAULT_COMPONENT_SPECS_DIR = "component-specs";
|
|
2886
2996
|
var CONFIG_NAME = "speclayer.json";
|
|
2887
2997
|
var invalidConfig = () => new Error(`${CONFIG_NAME} is not valid JSON. Fix or delete it, then retry.`);
|
|
2888
2998
|
function parseInclude(value) {
|
|
@@ -2914,6 +3024,14 @@ function parseDtcg(value) {
|
|
|
2914
3024
|
}
|
|
2915
3025
|
return out;
|
|
2916
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
|
+
}
|
|
2917
3035
|
function parsePlatforms(value) {
|
|
2918
3036
|
if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
|
|
2919
3037
|
throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
|
|
@@ -2934,7 +3052,7 @@ function parseOutputs(value) {
|
|
|
2934
3052
|
return outputs;
|
|
2935
3053
|
}
|
|
2936
3054
|
function readConfig(cwd) {
|
|
2937
|
-
const path =
|
|
3055
|
+
const path = join4(cwd, CONFIG_NAME);
|
|
2938
3056
|
if (!existsSync4(path)) return null;
|
|
2939
3057
|
let parsed;
|
|
2940
3058
|
try {
|
|
@@ -2947,6 +3065,7 @@ function readConfig(cwd) {
|
|
|
2947
3065
|
return {
|
|
2948
3066
|
...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
|
|
2949
3067
|
...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
|
|
3068
|
+
...record.componentSpecsDir !== void 0 ? { componentSpecsDir: parseComponentSpecsDir(record.componentSpecsDir) } : {},
|
|
2950
3069
|
...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
|
|
2951
3070
|
...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
|
|
2952
3071
|
...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
|
|
@@ -2957,18 +3076,19 @@ function writeConfig(cwd, config) {
|
|
|
2957
3076
|
const body = {
|
|
2958
3077
|
libraryId: config.libraryId,
|
|
2959
3078
|
outDir: config.outDir,
|
|
3079
|
+
...config.componentSpecsDir ? { componentSpecsDir: config.componentSpecsDir } : {},
|
|
2960
3080
|
...config.include ? { include: config.include } : {},
|
|
2961
3081
|
...config.dtcg ? { dtcg: config.dtcg } : {},
|
|
2962
3082
|
...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
|
|
2963
3083
|
...config.outputs ? { outputs: config.outputs } : {}
|
|
2964
3084
|
};
|
|
2965
|
-
writeFileSync3(
|
|
3085
|
+
writeFileSync3(join4(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
|
|
2966
3086
|
`);
|
|
2967
3087
|
}
|
|
2968
3088
|
function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
2969
3089
|
const config = readConfig(cwd);
|
|
2970
3090
|
const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
|
|
2971
|
-
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(
|
|
3091
|
+
const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join4(cwd, outDir));
|
|
2972
3092
|
const supplied = flags.key || env.SPEC_LAYER_KEY || null;
|
|
2973
3093
|
let storedKey = null;
|
|
2974
3094
|
let storedKeyFor;
|
|
@@ -2982,6 +3102,7 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
|
|
|
2982
3102
|
return {
|
|
2983
3103
|
libraryId,
|
|
2984
3104
|
outDir,
|
|
3105
|
+
componentSpecsDir: config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
|
|
2985
3106
|
// A trailing slash would build "//v1/..." paths the proxy router 404s on.
|
|
2986
3107
|
api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
|
|
2987
3108
|
key: supplied ?? storedKey,
|
|
@@ -3027,8 +3148,8 @@ async function fetchBundle(opts) {
|
|
|
3027
3148
|
}
|
|
3028
3149
|
|
|
3029
3150
|
// src/files.ts
|
|
3030
|
-
import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as
|
|
3031
|
-
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";
|
|
3032
3153
|
|
|
3033
3154
|
// src/selection.ts
|
|
3034
3155
|
var DEFAULT_SELECTION = { foundation: true, components: null };
|
|
@@ -3066,8 +3187,9 @@ function slugify(name) {
|
|
|
3066
3187
|
const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
3067
3188
|
return slug2 || "component";
|
|
3068
3189
|
}
|
|
3190
|
+
var COMPONENT_SPEC_MARKER = "spec_layer:\n kind: component";
|
|
3069
3191
|
function readManifest(outDir) {
|
|
3070
|
-
const path =
|
|
3192
|
+
const path = join5(outDir, "manifest.json");
|
|
3071
3193
|
if (!existsSync5(path)) return null;
|
|
3072
3194
|
try {
|
|
3073
3195
|
const parsed = JSON.parse(readFileSync5(path, "utf8"));
|
|
@@ -3084,7 +3206,7 @@ function readManifest(outDir) {
|
|
|
3084
3206
|
}
|
|
3085
3207
|
}
|
|
3086
3208
|
function readLocalBundle(outDir) {
|
|
3087
|
-
const path =
|
|
3209
|
+
const path = join5(outDir, "bundle.json");
|
|
3088
3210
|
if (!existsSync5(path)) return null;
|
|
3089
3211
|
try {
|
|
3090
3212
|
return parseBundle(readFileSync5(path, "utf8"));
|
|
@@ -3114,11 +3236,11 @@ function componentSlugs(bundle) {
|
|
|
3114
3236
|
});
|
|
3115
3237
|
}
|
|
3116
3238
|
function assertReplaceable(outDir, cwd) {
|
|
3117
|
-
const rel = relative2(
|
|
3239
|
+
const rel = relative2(resolve3(cwd), resolve3(outDir));
|
|
3118
3240
|
if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
|
|
3119
3241
|
throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
|
|
3120
3242
|
}
|
|
3121
|
-
if (existsSync5(outDir) && !existsSync5(
|
|
3243
|
+
if (existsSync5(outDir) && !existsSync5(join5(outDir, "manifest.json")) && readdirSync3(outDir).length > 0) {
|
|
3122
3244
|
throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
|
|
3123
3245
|
}
|
|
3124
3246
|
}
|
|
@@ -3128,13 +3250,23 @@ function writeBundleFiles(opts) {
|
|
|
3128
3250
|
const selected = selectComponents(opts.bundle, selection);
|
|
3129
3251
|
const slugs = componentSlugs(opts.bundle);
|
|
3130
3252
|
const outputs = opts.outputs ?? [];
|
|
3131
|
-
const
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
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.`);
|
|
3136
3267
|
}
|
|
3137
|
-
|
|
3268
|
+
briefs[`${slugs[i]}.yaml`] = component.ai;
|
|
3269
|
+
});
|
|
3138
3270
|
const staging = `${opts.outDir}.partial`;
|
|
3139
3271
|
rmSync2(staging, { recursive: true, force: true });
|
|
3140
3272
|
const written = [];
|
|
@@ -3142,8 +3274,8 @@ function writeBundleFiles(opts) {
|
|
|
3142
3274
|
const json = (v) => `${JSON.stringify(v, null, 2)}
|
|
3143
3275
|
`;
|
|
3144
3276
|
const put = (rel, content) => {
|
|
3145
|
-
const path =
|
|
3146
|
-
mkdirSync2(
|
|
3277
|
+
const path = join5(staging, rel);
|
|
3278
|
+
mkdirSync2(dirname(path), { recursive: true });
|
|
3147
3279
|
writeFileSync4(path, content);
|
|
3148
3280
|
written.push(rel);
|
|
3149
3281
|
};
|
|
@@ -3159,13 +3291,13 @@ function writeBundleFiles(opts) {
|
|
|
3159
3291
|
}
|
|
3160
3292
|
const exp = foundationDtcg(artifact, opts.dtcg ?? {});
|
|
3161
3293
|
for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
|
|
3162
|
-
path =
|
|
3294
|
+
path = `${outDirRel}/tokens/resolver.json`;
|
|
3163
3295
|
const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
|
|
3164
3296
|
for (const output of outputs) {
|
|
3165
3297
|
const rendered = renderOutput(exp, output, header);
|
|
3166
3298
|
put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
|
|
3167
3299
|
put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
|
|
3168
|
-
deliverables.push({ output,
|
|
3300
|
+
deliverables.push({ output, files: rendered.files });
|
|
3169
3301
|
}
|
|
3170
3302
|
}
|
|
3171
3303
|
artifacts.push({
|
|
@@ -3176,13 +3308,11 @@ function writeBundleFiles(opts) {
|
|
|
3176
3308
|
});
|
|
3177
3309
|
}
|
|
3178
3310
|
opts.bundle.components.forEach((component, i) => {
|
|
3179
|
-
const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
|
|
3180
|
-
if (path) put(path, component.ai);
|
|
3181
3311
|
artifacts.push({
|
|
3182
3312
|
kind: "component",
|
|
3183
3313
|
name: component.name,
|
|
3184
3314
|
contentHash: component.artifact.spec_layer.export.content_hash,
|
|
3185
|
-
path
|
|
3315
|
+
path: selected[i] ? `${componentSpecsDir}/${slugs[i]}.yaml` : null
|
|
3186
3316
|
});
|
|
3187
3317
|
});
|
|
3188
3318
|
const manifest = {
|
|
@@ -3192,6 +3322,7 @@ function writeBundleFiles(opts) {
|
|
|
3192
3322
|
pluginVersion: opts.bundle.pluginVersion,
|
|
3193
3323
|
extractorVersion: opts.bundle.extractorVersion,
|
|
3194
3324
|
selection,
|
|
3325
|
+
componentSpecsDir,
|
|
3195
3326
|
artifacts,
|
|
3196
3327
|
...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
|
|
3197
3328
|
...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
|
|
@@ -3204,18 +3335,18 @@ function writeBundleFiles(opts) {
|
|
|
3204
3335
|
}
|
|
3205
3336
|
rmSync2(opts.outDir, { recursive: true, force: true });
|
|
3206
3337
|
renameSync2(staging, opts.outDir);
|
|
3207
|
-
const
|
|
3338
|
+
const componentSpecs = { path: componentSpecsDir, files: writeVisibleDir(opts.cwd, componentSpecsDir, COMPONENT_SPEC_MARKER, briefs) };
|
|
3339
|
+
const outputResults = [];
|
|
3208
3340
|
for (const d of deliverables) {
|
|
3209
|
-
|
|
3210
|
-
outputPaths.push(d.output.path);
|
|
3341
|
+
outputResults.push({ path: d.output.path, files: writeVisibleDir(opts.cwd, d.output.path, CSS_HEADER_PREFIX, d.files, CSS_INDEX_FILE) });
|
|
3211
3342
|
}
|
|
3212
|
-
return { written, outputs:
|
|
3343
|
+
return { written, componentSpecs, outputs: outputResults };
|
|
3213
3344
|
}
|
|
3214
3345
|
|
|
3215
3346
|
// src/gitignore.ts
|
|
3216
3347
|
import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
|
|
3217
3348
|
import { spawnSync } from "node:child_process";
|
|
3218
|
-
import { join as
|
|
3349
|
+
import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
|
|
3219
3350
|
var COMMENT = "# Spec Layer pull key, not for committing";
|
|
3220
3351
|
function git(cwd, args) {
|
|
3221
3352
|
const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
|
|
@@ -3223,10 +3354,10 @@ function git(cwd, args) {
|
|
|
3223
3354
|
return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
|
|
3224
3355
|
}
|
|
3225
3356
|
function insideWorkTreeWithoutGit(cwd) {
|
|
3226
|
-
let dir =
|
|
3357
|
+
let dir = resolve4(cwd);
|
|
3227
3358
|
for (; ; ) {
|
|
3228
|
-
if (existsSync6(
|
|
3229
|
-
const parent =
|
|
3359
|
+
if (existsSync6(join6(dir, ".git"))) return true;
|
|
3360
|
+
const parent = dirname2(dir);
|
|
3230
3361
|
if (parent === dir) return false;
|
|
3231
3362
|
dir = parent;
|
|
3232
3363
|
}
|
|
@@ -3242,7 +3373,7 @@ function ensureIgnored(cwd, fileName) {
|
|
|
3242
3373
|
if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
|
|
3243
3374
|
const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
|
|
3244
3375
|
if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
|
|
3245
|
-
const path =
|
|
3376
|
+
const path = join6(cwd, ".gitignore");
|
|
3246
3377
|
const existed = existsSync6(path);
|
|
3247
3378
|
try {
|
|
3248
3379
|
if (!existed) {
|
|
@@ -3267,8 +3398,8 @@ ${fileName}
|
|
|
3267
3398
|
}
|
|
3268
3399
|
|
|
3269
3400
|
// src/skill.ts
|
|
3270
|
-
import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as
|
|
3271
|
-
import { dirname 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";
|
|
3272
3403
|
|
|
3273
3404
|
// src/tools.ts
|
|
3274
3405
|
var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
|
|
@@ -3286,7 +3417,8 @@ var TOOLS = [
|
|
|
3286
3417
|
"speclayer.local.json",
|
|
3287
3418
|
".gitignore (one line, when inside a git repo)",
|
|
3288
3419
|
"<outDir>/",
|
|
3289
|
-
"outputs[].path from speclayer.json (default
|
|
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"
|
|
3290
3422
|
],
|
|
3291
3423
|
exits: OK_OR_ERROR
|
|
3292
3424
|
},
|
|
@@ -3303,11 +3435,15 @@ var TOOLS = [
|
|
|
3303
3435
|
{
|
|
3304
3436
|
name: "pull",
|
|
3305
3437
|
usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
|
|
3306
|
-
summary: "Fetches the published library and writes
|
|
3307
|
-
when: "After setup, whenever status says the local copy is behind, or after changing the include
|
|
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.",
|
|
3308
3440
|
network: true,
|
|
3309
3441
|
needsKey: true,
|
|
3310
|
-
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
|
+
],
|
|
3311
3447
|
exits: OK_OR_ERROR
|
|
3312
3448
|
},
|
|
3313
3449
|
{
|
|
@@ -3416,24 +3552,24 @@ function readJson(path) {
|
|
|
3416
3552
|
}
|
|
3417
3553
|
function summarizePull(cwd, outDir, manifest) {
|
|
3418
3554
|
if (!manifest) return null;
|
|
3419
|
-
const absOut =
|
|
3420
|
-
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path
|
|
3555
|
+
const absOut = join7(cwd, outDir);
|
|
3556
|
+
const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path }));
|
|
3421
3557
|
const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
|
|
3422
3558
|
let foundation = null;
|
|
3423
3559
|
if (foundationEntry) {
|
|
3424
|
-
const tokensDir =
|
|
3425
|
-
const resolver = readJson(
|
|
3426
|
-
const report2 = readJson(
|
|
3560
|
+
const tokensDir = join7(absOut, "tokens");
|
|
3561
|
+
const resolver = readJson(join7(tokensDir, "resolver.json"));
|
|
3562
|
+
const report2 = readJson(join7(tokensDir, "report.json"));
|
|
3427
3563
|
let tokenFiles = [];
|
|
3428
3564
|
try {
|
|
3429
|
-
tokenFiles =
|
|
3565
|
+
tokenFiles = readdirSync4(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
|
|
3430
3566
|
} catch {
|
|
3431
3567
|
tokenFiles = [];
|
|
3432
3568
|
}
|
|
3433
3569
|
let unitlessNumbers = 0;
|
|
3434
3570
|
for (const file of tokenFiles) {
|
|
3435
3571
|
if (file.startsWith("styles.")) continue;
|
|
3436
|
-
unitlessNumbers += countNumberTokens(readJson(
|
|
3572
|
+
unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
|
|
3437
3573
|
}
|
|
3438
3574
|
const reportCounts = {};
|
|
3439
3575
|
if (Array.isArray(report2)) {
|
|
@@ -3459,20 +3595,33 @@ function summarizePull(cwd, outDir, manifest) {
|
|
|
3459
3595
|
libraryId: manifest.libraryId,
|
|
3460
3596
|
publishedAt: manifest.publishedAt,
|
|
3461
3597
|
pluginVersion: manifest.pluginVersion,
|
|
3598
|
+
componentSpecsDir: manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
|
|
3462
3599
|
components,
|
|
3463
3600
|
foundation,
|
|
3464
|
-
outputs: (manifest.outputs ?? []).map((o) =>
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
|
|
3471
|
-
|
|
3472
|
-
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
|
|
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
|
+
})
|
|
3476
3625
|
};
|
|
3477
3626
|
}
|
|
3478
3627
|
var code = (s) => `\`${s}\``;
|
|
@@ -3511,16 +3660,17 @@ function stackSection(input) {
|
|
|
3511
3660
|
if (cssOut) {
|
|
3512
3661
|
const mapPath = `${input.outDir}/outputs/web-css.map.json`;
|
|
3513
3662
|
lines.push(
|
|
3514
|
-
`The CSS custom property for every token is in ${code(mapPath)}: source "code_syntax" when the designer declared it in Figma, "derived" when the CLI built it from the DTCG path by the stated rule (${cssOut.case} case, collection root included). Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
|
|
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.`,
|
|
3515
3664
|
""
|
|
3516
3665
|
);
|
|
3666
|
+
const partFiles = cssOut.files.filter((f) => f !== CSS_INDEX_FILE);
|
|
3517
3667
|
lines.push(
|
|
3518
|
-
`Import ${code(cssOut.path)} from the root stylesheet. It
|
|
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.`,
|
|
3519
3669
|
""
|
|
3520
3670
|
);
|
|
3521
3671
|
if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
|
|
3522
3672
|
lines.push(
|
|
3523
|
-
`${code(cssOut.path)} is a projection of the same ${code(
|
|
3673
|
+
`${code(`${cssOut.path}/`)} is a projection of the same ${code(tokensDir)} files, not a second source. Import one or the other.`,
|
|
3524
3674
|
""
|
|
3525
3675
|
);
|
|
3526
3676
|
}
|
|
@@ -3530,14 +3680,19 @@ function stackSection(input) {
|
|
|
3530
3680
|
""
|
|
3531
3681
|
);
|
|
3532
3682
|
const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
|
|
3533
|
-
if (configuredNotWritten) {
|
|
3683
|
+
if (configuredNotWritten?.indexMissing) {
|
|
3534
3684
|
lines.push(
|
|
3535
|
-
`A web/css output is configured at ${code(configuredNotWritten.path)} but
|
|
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.`,
|
|
3536
3691
|
""
|
|
3537
3692
|
);
|
|
3538
3693
|
} else if (pull?.foundation?.written) {
|
|
3539
3694
|
lines.push(
|
|
3540
|
-
`No token file was written for web. Add \`"outputs"\` in \`speclayer.json\` (or run \`spec-layer pull --platform web\` once) and pull again; the default lands at ${code("
|
|
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/")}.`,
|
|
3541
3696
|
""
|
|
3542
3697
|
);
|
|
3543
3698
|
}
|
|
@@ -3619,9 +3774,9 @@ function pullSection(input) {
|
|
|
3619
3774
|
} else {
|
|
3620
3775
|
lines.push("- This library has no Foundation, so there is no tokens/ directory.");
|
|
3621
3776
|
}
|
|
3622
|
-
lines.push(`- ${code(`${
|
|
3777
|
+
lines.push(`- ${code(`${pull.componentSpecsDir}/`)}: one YAML per component.`);
|
|
3623
3778
|
for (const o of pull.outputs) {
|
|
3624
|
-
lines.push(o.written ? `- ${code(o.path)}: ${o.platform}/${o.format} token
|
|
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.`);
|
|
3625
3780
|
}
|
|
3626
3781
|
lines.push("");
|
|
3627
3782
|
if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
|
|
@@ -3676,20 +3831,21 @@ function buildSkillGuide(input) {
|
|
|
3676
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.`,
|
|
3677
3832
|
""
|
|
3678
3833
|
);
|
|
3834
|
+
const componentSpecsDir = input.pull?.componentSpecsDir ?? input.config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
|
|
3679
3835
|
lines.push("## How to use it", "");
|
|
3680
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.`);
|
|
3681
|
-
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.`);
|
|
3682
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.`);
|
|
3683
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.`);
|
|
3684
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.`);
|
|
3685
3841
|
const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
|
|
3686
|
-
const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(o.path)).join(", ")} either: pull replaces
|
|
3687
|
-
lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces
|
|
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.`);
|
|
3688
3844
|
lines.push("");
|
|
3689
3845
|
lines.push(...pullSection(input));
|
|
3690
3846
|
lines.push(...stackSection(input));
|
|
3691
3847
|
lines.push(...commandsSection());
|
|
3692
|
-
lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
|
|
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.`);
|
|
3693
3849
|
return `${lines.join("\n")}
|
|
3694
3850
|
`;
|
|
3695
3851
|
}
|
|
@@ -3759,16 +3915,16 @@ ${BLOCK_END}
|
|
|
3759
3915
|
const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
|
|
3760
3916
|
return `${existing.slice(0, begin)}${block}${after}`;
|
|
3761
3917
|
}
|
|
3762
|
-
const
|
|
3763
|
-
return `${existing}${
|
|
3918
|
+
const sep2 = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
3919
|
+
return `${existing}${sep2}${block}`;
|
|
3764
3920
|
}
|
|
3765
3921
|
function installSkill(cwd, host, guide) {
|
|
3766
3922
|
const target = installTarget(host);
|
|
3767
|
-
const abs =
|
|
3923
|
+
const abs = join7(cwd, target.path);
|
|
3768
3924
|
const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
|
|
3769
3925
|
const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
|
|
3770
3926
|
if (existing === next) return { path: target.path, result: "unchanged" };
|
|
3771
|
-
mkdirSync3(
|
|
3927
|
+
mkdirSync3(dirname3(abs), { recursive: true });
|
|
3772
3928
|
writeFileSync6(abs, next);
|
|
3773
3929
|
return { path: target.path, result: existing === null ? "created" : "updated" };
|
|
3774
3930
|
}
|
|
@@ -3796,7 +3952,7 @@ function manifestReader() {
|
|
|
3796
3952
|
function sameOutput(a, b) {
|
|
3797
3953
|
const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
|
|
3798
3954
|
const key = (v) => JSON.stringify(sortKeys(v ?? {}));
|
|
3799
|
-
return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []);
|
|
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);
|
|
3800
3956
|
}
|
|
3801
3957
|
function sortKeys(value) {
|
|
3802
3958
|
if (Array.isArray(value)) return value.map(sortKeys);
|
|
@@ -3827,12 +3983,12 @@ function outputsForRun(fromFlags, config, platforms) {
|
|
|
3827
3983
|
if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
|
|
3828
3984
|
return config?.outputs ?? defaultOutputs(platforms);
|
|
3829
3985
|
}
|
|
3830
|
-
var NO_PLATFORM_NOTE = `No target platform detected, so no token
|
|
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.`;
|
|
3831
3987
|
function platformsMissingFormat(platforms) {
|
|
3832
3988
|
return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
|
|
3833
3989
|
}
|
|
3834
3990
|
function missingFormatNote(platforms) {
|
|
3835
|
-
return `No token
|
|
3991
|
+
return `No token files exist yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
|
|
3836
3992
|
}
|
|
3837
3993
|
var errorText = (err) => err instanceof Error ? err.message : String(err);
|
|
3838
3994
|
function runInit(cwd, flags, io2) {
|
|
@@ -3855,12 +4011,13 @@ function runInit(cwd, flags, io2) {
|
|
|
3855
4011
|
writeConfig(cwd, {
|
|
3856
4012
|
libraryId: flags.id,
|
|
3857
4013
|
outDir,
|
|
4014
|
+
componentSpecsDir: DEFAULT_COMPONENT_SPECS_DIR,
|
|
3858
4015
|
...include ? { include } : {},
|
|
3859
4016
|
...platforms.length > 0 ? { platforms } : {},
|
|
3860
4017
|
...outputs.length > 0 ? { outputs } : {}
|
|
3861
4018
|
});
|
|
3862
4019
|
io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
|
|
3863
|
-
for (const o of outputs) io2.out(`Token
|
|
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.`);
|
|
3864
4021
|
if (source === "flag" || source === "detected") {
|
|
3865
4022
|
const missing = platformsMissingFormat(platforms);
|
|
3866
4023
|
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
@@ -3888,7 +4045,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
|
|
|
3888
4045
|
}
|
|
3889
4046
|
function resolvedOutDir(cwd, flags, io2) {
|
|
3890
4047
|
try {
|
|
3891
|
-
return
|
|
4048
|
+
return join8(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
|
|
3892
4049
|
} catch (err) {
|
|
3893
4050
|
io2.err(errorText(err));
|
|
3894
4051
|
return null;
|
|
@@ -3927,6 +4084,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
|
|
|
3927
4084
|
const fromFlags = platformsFromFlags(flags, io2);
|
|
3928
4085
|
if (fromFlags === null) return 1;
|
|
3929
4086
|
const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
|
|
4087
|
+
const componentSpecsDir = existing?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
|
|
3930
4088
|
const keptInclude = include ?? existing?.include ?? null;
|
|
3931
4089
|
const keptDtcg = existing?.dtcg ?? null;
|
|
3932
4090
|
const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
|
|
@@ -3934,6 +4092,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
|
|
|
3934
4092
|
writeConfig(cwd, {
|
|
3935
4093
|
libraryId: flags.id,
|
|
3936
4094
|
outDir,
|
|
4095
|
+
componentSpecsDir,
|
|
3937
4096
|
...keptInclude ? { include: keptInclude } : {},
|
|
3938
4097
|
...keptDtcg ? { dtcg: keptDtcg } : {},
|
|
3939
4098
|
...platforms.length > 0 ? { platforms } : {},
|
|
@@ -3986,6 +4145,12 @@ git rm --cached ${ignored.line}`);
|
|
|
3986
4145
|
io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
|
|
3987
4146
|
return 0;
|
|
3988
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
|
+
}
|
|
3989
4154
|
async function runPull(cwd, flags, env, io2, fetcher) {
|
|
3990
4155
|
const manifestAt = manifestReader();
|
|
3991
4156
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
@@ -4001,13 +4166,14 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4001
4166
|
if (fromFlags === null) return 1;
|
|
4002
4167
|
const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
|
|
4003
4168
|
const outputs = outputsForRun(fromFlags, opts, platforms);
|
|
4004
|
-
const manifest = manifestAt(
|
|
4169
|
+
const manifest = manifestAt(join8(cwd, opts.outDir));
|
|
4005
4170
|
const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
|
|
4006
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)));
|
|
4007
4173
|
const etag = manifest && sameOutput(
|
|
4008
|
-
{ selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs },
|
|
4009
|
-
{ selection, dtcg: opts.dtcg, outputs }
|
|
4010
|
-
) && (!willWriteFoundation || outputs.every((o) =>
|
|
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;
|
|
4011
4177
|
const result = await fetchBundle({
|
|
4012
4178
|
api: opts.api,
|
|
4013
4179
|
libraryId: opts.libraryId,
|
|
@@ -4024,12 +4190,13 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4024
4190
|
return 0;
|
|
4025
4191
|
}
|
|
4026
4192
|
let written;
|
|
4027
|
-
let
|
|
4193
|
+
let componentSpecs;
|
|
4194
|
+
let outputResults;
|
|
4028
4195
|
try {
|
|
4029
4196
|
const bundle = parseBundle(result.raw);
|
|
4030
4197
|
const selected = selectComponents(bundle, selection);
|
|
4031
4198
|
const writeResult = writeBundleFiles({
|
|
4032
|
-
outDir:
|
|
4199
|
+
outDir: join8(cwd, opts.outDir),
|
|
4033
4200
|
cwd,
|
|
4034
4201
|
raw: result.raw,
|
|
4035
4202
|
bundle,
|
|
@@ -4039,10 +4206,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4039
4206
|
bundleHash: result.bundleHash,
|
|
4040
4207
|
dtcg: opts.dtcg,
|
|
4041
4208
|
platforms,
|
|
4042
|
-
outputs
|
|
4209
|
+
outputs,
|
|
4210
|
+
componentSpecsDir: opts.componentSpecsDir
|
|
4043
4211
|
});
|
|
4044
4212
|
written = writeResult.written;
|
|
4045
|
-
|
|
4213
|
+
componentSpecs = writeResult.componentSpecs;
|
|
4214
|
+
outputResults = writeResult.outputs;
|
|
4046
4215
|
io2.out(
|
|
4047
4216
|
`Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
|
|
4048
4217
|
);
|
|
@@ -4050,12 +4219,24 @@ async function runPull(cwd, flags, env, io2, fetcher) {
|
|
|
4050
4219
|
io2.err(errorText(err));
|
|
4051
4220
|
return 1;
|
|
4052
4221
|
}
|
|
4222
|
+
const count = (n) => `${n} file${n === 1 ? "" : "s"}`;
|
|
4053
4223
|
io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
|
|
4054
|
-
|
|
4055
|
-
|
|
4056
|
-
|
|
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);
|
|
4057
4238
|
}
|
|
4058
|
-
if (
|
|
4239
|
+
if (outputResults.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
|
|
4059
4240
|
if (source === "flag" || source === "config") {
|
|
4060
4241
|
const missing = platformsMissingFormat(platforms);
|
|
4061
4242
|
if (missing.length > 0) io2.out(missingFormatNote(missing));
|
|
@@ -4066,7 +4247,7 @@ async function runStatus(cwd, flags, env, io2, fetcher) {
|
|
|
4066
4247
|
const manifestAt = manifestReader();
|
|
4067
4248
|
const opts = resolved(cwd, flags, env, io2, manifestAt);
|
|
4068
4249
|
if (!opts) return 1;
|
|
4069
|
-
const manifest = manifestAt(
|
|
4250
|
+
const manifest = manifestAt(join8(cwd, opts.outDir));
|
|
4070
4251
|
if (!manifest) {
|
|
4071
4252
|
io2.err(NO_LOCAL_PULL);
|
|
4072
4253
|
return 2;
|
|
@@ -4104,7 +4285,7 @@ function runList(cwd, flags, io2) {
|
|
|
4104
4285
|
io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
|
|
4105
4286
|
}
|
|
4106
4287
|
for (const o of manifest.outputs ?? []) {
|
|
4107
|
-
const written = existsSync8(
|
|
4288
|
+
const written = existsSync8(join8(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
|
|
4108
4289
|
io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
|
|
4109
4290
|
}
|
|
4110
4291
|
return 0;
|
|
@@ -4174,7 +4355,7 @@ function collectSkillInput(cwd, flags, io2) {
|
|
|
4174
4355
|
const fromFlags = platformsFromFlags(flags, io2);
|
|
4175
4356
|
if (fromFlags === null) return null;
|
|
4176
4357
|
const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
|
|
4177
|
-
const pull = summarizePull(cwd, outDir, readManifest(
|
|
4358
|
+
const pull = summarizePull(cwd, outDir, readManifest(join8(cwd, outDir)));
|
|
4178
4359
|
return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
|
|
4179
4360
|
}
|
|
4180
4361
|
function skillHosts(flags, input, io2) {
|