@smallpen/cli 0.1.0-alpha.2 → 0.1.0-alpha.5
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/bin/help.mjs +236 -32
- package/bin/schema.mjs +615 -0
- package/bin/smallpen.mjs +500 -64
- package/bin/warning-output.mjs +8 -1
- package/package.json +3 -3
package/bin/smallpen.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import {
|
|
5
5
|
mkdir,
|
|
6
6
|
readdir,
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
createCompareView,
|
|
21
21
|
createInitializationState,
|
|
22
22
|
diffDrafts,
|
|
23
|
-
|
|
23
|
+
designTokenAdviceForBatch,
|
|
24
24
|
draftFromSnapshot,
|
|
25
25
|
explainEffectiveToken,
|
|
26
26
|
importTokens,
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
applyOperationBatch,
|
|
37
37
|
createEvidence,
|
|
38
38
|
defaultLibraryCacheRoot,
|
|
39
|
+
fontVariantName,
|
|
39
40
|
importDraft,
|
|
40
41
|
importFontVariant,
|
|
41
42
|
importMedia,
|
|
@@ -52,11 +53,13 @@ import {
|
|
|
52
53
|
} from "@smallpen/local-package";
|
|
53
54
|
|
|
54
55
|
import { COMMAND_NAMES, printHelp } from "./help.mjs";
|
|
56
|
+
import { schemaTopic } from "./schema.mjs";
|
|
55
57
|
import { warningOutput } from "./warning-output.mjs";
|
|
56
58
|
|
|
57
59
|
const FLAG_OPTIONS = new Set([
|
|
58
60
|
"--apply",
|
|
59
61
|
"--base64",
|
|
62
|
+
"--compact",
|
|
60
63
|
"--confirm",
|
|
61
64
|
"--confirm-unmatched",
|
|
62
65
|
"--diff",
|
|
@@ -67,9 +70,22 @@ const FLAG_OPTIONS = new Set([
|
|
|
67
70
|
"--json",
|
|
68
71
|
"-h",
|
|
69
72
|
]);
|
|
73
|
+
// Value options read with options(); every other value option is single.
|
|
74
|
+
const REPEATABLE_OPTIONS = new Set(["--answer", "--context", "--select", "--selector"]);
|
|
70
75
|
const COMMAND_OPTIONS = {
|
|
71
76
|
version: ["--json"],
|
|
72
|
-
|
|
77
|
+
schema: ["--json"],
|
|
78
|
+
apply: [
|
|
79
|
+
"--batch",
|
|
80
|
+
"--compact",
|
|
81
|
+
"--confirm-unmatched",
|
|
82
|
+
"--diff",
|
|
83
|
+
"--dry-run",
|
|
84
|
+
"--explain",
|
|
85
|
+
"--inverse-out",
|
|
86
|
+
"--json",
|
|
87
|
+
"--warning-detail",
|
|
88
|
+
],
|
|
73
89
|
catalog: ["--context", "--json"],
|
|
74
90
|
component: ["--component-id", "--json"],
|
|
75
91
|
compare: ["--json", "--selector"],
|
|
@@ -114,17 +130,52 @@ const COMMAND_OPTIONS = {
|
|
|
114
130
|
],
|
|
115
131
|
"render-matrix": ["--contexts", "--json", "--output", "--scale"],
|
|
116
132
|
impact: ["--json", "--package-id", "--path", "--token-id"],
|
|
117
|
-
flow: [
|
|
118
|
-
|
|
119
|
-
|
|
133
|
+
flow: [
|
|
134
|
+
"--batch-id",
|
|
135
|
+
"--compact",
|
|
136
|
+
"--confirm-unmatched",
|
|
137
|
+
"--diff",
|
|
138
|
+
"--dry-run",
|
|
139
|
+
"--explain",
|
|
140
|
+
"--intent",
|
|
141
|
+
"--inverse-out",
|
|
142
|
+
"--json",
|
|
143
|
+
"--warning-detail",
|
|
144
|
+
],
|
|
145
|
+
page: [
|
|
146
|
+
"--batch-id",
|
|
147
|
+
"--compact",
|
|
148
|
+
"--confirm-unmatched",
|
|
149
|
+
"--diff",
|
|
150
|
+
"--dry-run",
|
|
151
|
+
"--explain",
|
|
152
|
+
"--intent",
|
|
153
|
+
"--inverse-out",
|
|
154
|
+
"--json",
|
|
155
|
+
"--warning-detail",
|
|
156
|
+
],
|
|
157
|
+
token: [
|
|
158
|
+
"--batch-id",
|
|
159
|
+
"--compact",
|
|
160
|
+
"--confirm-unmatched",
|
|
161
|
+
"--diff",
|
|
162
|
+
"--dry-run",
|
|
163
|
+
"--explain",
|
|
164
|
+
"--intent",
|
|
165
|
+
"--inverse-out",
|
|
166
|
+
"--json",
|
|
167
|
+
"--warning-detail",
|
|
168
|
+
],
|
|
120
169
|
"explain-token": ["--context", "--json", "--package-id", "--token-id"],
|
|
121
170
|
init: ["--answer", "--answers", "--confirm", "--json", "--locale", "--state"],
|
|
122
171
|
"import-draft": ["--input", "--json", "--kind", "--package-id"],
|
|
123
172
|
"import-tokens": [
|
|
124
173
|
"--apply",
|
|
125
174
|
"--batch-id",
|
|
175
|
+
"--compact",
|
|
126
176
|
"--dry-run",
|
|
127
177
|
"--input",
|
|
178
|
+
"--inverse-out",
|
|
128
179
|
"--json",
|
|
129
180
|
"--select",
|
|
130
181
|
"--set-name",
|
|
@@ -135,14 +186,13 @@ const COMMAND_OPTIONS = {
|
|
|
135
186
|
"--file",
|
|
136
187
|
"--font-id",
|
|
137
188
|
"--json",
|
|
138
|
-
"--media-path",
|
|
139
189
|
"--name",
|
|
140
190
|
"--style",
|
|
141
191
|
"--variant-id",
|
|
142
192
|
"--weight",
|
|
143
193
|
],
|
|
144
194
|
"import-media": ["--file", "--json", "--media-id", "--media-path", "--name"],
|
|
145
|
-
"library-refresh": ["--
|
|
195
|
+
"library-refresh": ["--json", "--library"],
|
|
146
196
|
watch: ["--interval", "--json", "--max-events"],
|
|
147
197
|
list: ["--json", "--kind", "--limit", "--offset"],
|
|
148
198
|
read: ["--json"],
|
|
@@ -191,14 +241,51 @@ const COMMAND_OPTIONS = {
|
|
|
191
241
|
"--type",
|
|
192
242
|
"--value",
|
|
193
243
|
],
|
|
194
|
-
tokens: ["--context", "--json"],
|
|
244
|
+
tokens: ["--context", "--json", "--locale"],
|
|
195
245
|
validate: ["--json"],
|
|
196
246
|
};
|
|
197
247
|
|
|
248
|
+
// Localized labels follow the environment unless --locale is given: any
|
|
249
|
+
// zh* locale selects the Chinese labels, everything else English.
|
|
250
|
+
function defaultLocale() {
|
|
251
|
+
const source =
|
|
252
|
+
process.env.LC_ALL || process.env.LC_MESSAGES || process.env.LANG || "";
|
|
253
|
+
return /^zh/i.test(source) ? "zh-TW" : "en";
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function tokenLabels(locale) {
|
|
257
|
+
return /^zh/i.test(locale)
|
|
258
|
+
? { context: "設計狀態", items: "有效設計變數" }
|
|
259
|
+
: { context: "Design Context", items: "Effective Tokens" };
|
|
260
|
+
}
|
|
261
|
+
|
|
198
262
|
function isRecord(value) {
|
|
199
263
|
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
200
264
|
}
|
|
201
265
|
|
|
266
|
+
// Locale-independent order for JSON output: localeCompare follows the host
|
|
267
|
+
// LANG (Danish sorts "aa" after "ab"), which would reorder lists and pages.
|
|
268
|
+
function compareText(left, right) {
|
|
269
|
+
return left < right ? -1 : left > right ? 1 : 0;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Caller-supplied input files fail with a typed error instead of a raw
|
|
273
|
+
// filesystem internal_error.
|
|
274
|
+
async function readInputFile(path, optionName, encoding) {
|
|
275
|
+
try {
|
|
276
|
+
return await readFile(path, encoding);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
if (!["EACCES", "EISDIR", "ENOENT", "ENOTDIR", "EPERM"].includes(error?.code)) {
|
|
279
|
+
throw error;
|
|
280
|
+
}
|
|
281
|
+
throw new SmallPenError(
|
|
282
|
+
"unreadable_input_file",
|
|
283
|
+
`${optionName} file cannot be read: ${error.message}`,
|
|
284
|
+
{ option: optionName, path: resolve(path), reason: error.code },
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
202
289
|
function option(args, name) {
|
|
203
290
|
const index = args.indexOf(name);
|
|
204
291
|
if (index === -1) return undefined;
|
|
@@ -240,6 +327,7 @@ function validateCommandArguments(command, args) {
|
|
|
240
327
|
"--help",
|
|
241
328
|
"-h",
|
|
242
329
|
]);
|
|
330
|
+
const seen = new Set();
|
|
243
331
|
for (let index = 2; index < args.length; index += 1) {
|
|
244
332
|
const value = args[index];
|
|
245
333
|
if (!value.startsWith("-")) {
|
|
@@ -256,7 +344,18 @@ function validateCommandArguments(command, args) {
|
|
|
256
344
|
validOptions: [...allowed].sort(),
|
|
257
345
|
});
|
|
258
346
|
}
|
|
259
|
-
if (
|
|
347
|
+
if (FLAG_OPTIONS.has(value)) continue;
|
|
348
|
+
// option() reads only the first occurrence; a second value would be
|
|
349
|
+
// silently ignored.
|
|
350
|
+
if (seen.has(value) && !REPEATABLE_OPTIONS.has(value)) {
|
|
351
|
+
throw new SmallPenError(
|
|
352
|
+
"duplicate_option",
|
|
353
|
+
`${value} may be given only once`,
|
|
354
|
+
{ command, option: value },
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
seen.add(value);
|
|
358
|
+
index += 1;
|
|
260
359
|
}
|
|
261
360
|
}
|
|
262
361
|
|
|
@@ -343,7 +442,7 @@ function printJson(value) {
|
|
|
343
442
|
child instanceof Map
|
|
344
443
|
? Object.fromEntries(
|
|
345
444
|
[...child.entries()].sort(([left], [right]) =>
|
|
346
|
-
String(left)
|
|
445
|
+
compareText(String(left), String(right)),
|
|
347
446
|
),
|
|
348
447
|
)
|
|
349
448
|
: child,
|
|
@@ -381,7 +480,7 @@ function inspect(snapshot) {
|
|
|
381
480
|
screenId,
|
|
382
481
|
}),
|
|
383
482
|
),
|
|
384
|
-
].sort((left, right) => left.id
|
|
483
|
+
].sort((left, right) => compareText(left.id, right.id)),
|
|
385
484
|
contexts: {
|
|
386
485
|
axes: [...snapshot.domain.contextAxes.values()],
|
|
387
486
|
profiles: [...snapshot.domain.contextProfiles.values()],
|
|
@@ -613,7 +712,42 @@ function explainBatch(before, prepared, batch) {
|
|
|
613
712
|
return { diff, explain };
|
|
614
713
|
}
|
|
615
714
|
|
|
715
|
+
// The full reply carries the inverse batch twice (inverseBatch and
|
|
716
|
+
// guidance.undo), which is most of its size. inverseBatch stays in the
|
|
717
|
+
// default reply because undo relies on it; --inverse-out saves it to a file
|
|
718
|
+
// and --compact drops it from the reply in favour of a short summary.
|
|
719
|
+
async function writeReply(result, args) {
|
|
720
|
+
const inverseOut = option(args, "--inverse-out");
|
|
721
|
+
const inverseBatchPath =
|
|
722
|
+
inverseOut !== undefined && result.inverseBatch && !result.dryRun
|
|
723
|
+
? await writeAtomic(inverseOut, `${JSON.stringify(result.inverseBatch, null, 2)}\n`)
|
|
724
|
+
: undefined;
|
|
725
|
+
if (!flag(args, "--compact")) {
|
|
726
|
+
return inverseBatchPath ? { ...result, inverseBatchPath } : result;
|
|
727
|
+
}
|
|
728
|
+
const { guidance: _guidance, inverseBatch, ...rest } = result;
|
|
729
|
+
const files = (result.changedFiles?.length ?? 0) + (result.deletedFiles?.length ?? 0);
|
|
730
|
+
const warnings = result.warningSummary?.total ?? result.warnings?.length ?? 0;
|
|
731
|
+
const verb = result.dryRun ? "Previewed" : result.alreadyApplied ? "Already applied" : "Applied";
|
|
732
|
+
return {
|
|
733
|
+
...rest,
|
|
734
|
+
...(inverseBatch ? { inverseOperationCount: inverseBatch.operations.length } : {}),
|
|
735
|
+
...(inverseBatchPath ? { inverseBatchPath } : {}),
|
|
736
|
+
summary:
|
|
737
|
+
`${verb} ${result.batchId}: revision ${result.revision}, ` +
|
|
738
|
+
`${files} changed file(s), ${result.affectedIds?.length ?? 0} affected id(s), ` +
|
|
739
|
+
`${warnings} warning(s).` +
|
|
740
|
+
(inverseBatchPath || !inverseBatch || result.dryRun
|
|
741
|
+
? ""
|
|
742
|
+
: " The inverse batch was not kept: rerun writes with --inverse-out FILE to save it for undo."),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
|
|
616
746
|
async function applyBatch(packagePath, batch, args) {
|
|
747
|
+
return writeReply(await prepareAndApplyBatch(packagePath, batch, args), args);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
async function prepareAndApplyBatch(packagePath, batch, args) {
|
|
617
751
|
const detail = option(args, "--warning-detail") ?? "compact";
|
|
618
752
|
if (!["compact", "full"].includes(detail)) {
|
|
619
753
|
throw new SmallPenError("invalid_warning_detail", "--warning-detail must be compact or full", {
|
|
@@ -626,11 +760,27 @@ async function applyBatch(packagePath, batch, args) {
|
|
|
626
760
|
"CLI Operation Batch JSON cannot contain binary blob writes",
|
|
627
761
|
);
|
|
628
762
|
}
|
|
763
|
+
// High-level commands rebuild their batch from the current revision, so a
|
|
764
|
+
// retry with the same --batch-id after success carries the recorded result
|
|
765
|
+
// as its base and may no longer prepare (its nodes already exist). Consult
|
|
766
|
+
// the ledger first; previews never write and skip it.
|
|
767
|
+
if (
|
|
768
|
+
args[0] !== "apply" &&
|
|
769
|
+
option(args, "--batch-id") !== undefined &&
|
|
770
|
+
!flag(args, "--dry-run") &&
|
|
771
|
+
!flag(args, "--explain") &&
|
|
772
|
+
!flag(args, "--diff")
|
|
773
|
+
) {
|
|
774
|
+
const replayed = await replayRecordedBatch(packagePath, batch, { rebuilt: true });
|
|
775
|
+
if (replayed) return replayed;
|
|
776
|
+
}
|
|
629
777
|
// 通用 apply 仍然能修复处于 Repair 的 Product;Token 建议不能变成写入门禁。
|
|
630
|
-
const { foundation, product } = await tokenAdviceWorkspace(packagePath);
|
|
778
|
+
const { foundation, libraries, product } = await tokenAdviceWorkspace(packagePath);
|
|
631
779
|
let prepared;
|
|
632
780
|
try {
|
|
633
|
-
|
|
781
|
+
// Foundation and Library components let preparation check Instance
|
|
782
|
+
// override paths that target them.
|
|
783
|
+
prepared = await prepareOperationBatch(product, batch, { foundation, libraries });
|
|
634
784
|
} catch (error) {
|
|
635
785
|
if (error?.code === "stale_revision") {
|
|
636
786
|
const replayed = await replayRecordedBatch(packagePath, batch);
|
|
@@ -638,13 +788,17 @@ async function applyBatch(packagePath, batch, args) {
|
|
|
638
788
|
}
|
|
639
789
|
throw error;
|
|
640
790
|
}
|
|
641
|
-
const warnings =
|
|
791
|
+
const { suppressed, warnings } = designTokenAdviceForBatch(prepared.snapshot, batch, {
|
|
642
792
|
foundation,
|
|
643
793
|
});
|
|
644
794
|
// Import warnings have their own schema and are not design-style advice.
|
|
645
795
|
const advice = args[0] === "import-tokens"
|
|
646
796
|
? { warnings }
|
|
647
|
-
: warningOutput(warnings, detail, {
|
|
797
|
+
: warningOutput(warnings, detail, {
|
|
798
|
+
confirmUnmatched: flag(args, "--confirm-unmatched"),
|
|
799
|
+
suppressed,
|
|
800
|
+
});
|
|
801
|
+
const change = changeSummary(product, prepared, batch);
|
|
648
802
|
if (flag(args, "--explain") || flag(args, "--diff")) {
|
|
649
803
|
// explain/diff are read-only previews in any combination; neither flag
|
|
650
804
|
// writes, with or without --dry-run (CLI-AI-TOPIC: "explain and diff
|
|
@@ -652,19 +806,37 @@ async function applyBatch(packagePath, batch, args) {
|
|
|
652
806
|
return {
|
|
653
807
|
...prepared.result,
|
|
654
808
|
...explainBatch(product, prepared, batch),
|
|
809
|
+
...change,
|
|
655
810
|
dryRun: true,
|
|
656
811
|
...advice,
|
|
657
812
|
};
|
|
658
813
|
}
|
|
659
814
|
if (flag(args, "--dry-run")) {
|
|
660
|
-
return { ...prepared.result, dryRun: true, ...advice };
|
|
815
|
+
return { ...prepared.result, ...change, dryRun: true, ...advice };
|
|
661
816
|
}
|
|
662
817
|
return {
|
|
663
818
|
...(await applyOperationBatch(packagePath, batch)),
|
|
819
|
+
...change,
|
|
664
820
|
...advice,
|
|
665
821
|
};
|
|
666
822
|
}
|
|
667
823
|
|
|
824
|
+
// A valid batch can still leave every byte as it was (for example a value
|
|
825
|
+
// that is already set). Say so instead of reporting a silent success.
|
|
826
|
+
function changeSummary(before, prepared, batch) {
|
|
827
|
+
if (prepared.snapshot.revision !== before.revision) return { changed: true };
|
|
828
|
+
return {
|
|
829
|
+
changed: false,
|
|
830
|
+
noChange: {
|
|
831
|
+
code: "no_change",
|
|
832
|
+
message:
|
|
833
|
+
`The batch is valid but changes nothing: the ${batch.operations.length} ` +
|
|
834
|
+
"operation(s) leave the Package at its current revision. Check the " +
|
|
835
|
+
"target ids and values (smallpen read-view PACKAGE --format semantic --json).",
|
|
836
|
+
},
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
|
|
668
840
|
function searchValue(args) {
|
|
669
841
|
const color = option(args, "--color");
|
|
670
842
|
const source = option(args, "--value");
|
|
@@ -799,17 +971,24 @@ async function initializeCommand(workspacePath, args) {
|
|
|
799
971
|
const answersPath = option(args, "--answers");
|
|
800
972
|
if (answersPath) {
|
|
801
973
|
fileAnswers = parseJson(
|
|
802
|
-
await
|
|
974
|
+
await readInputFile(answersPath, "--answers", "utf8"),
|
|
803
975
|
"invalid_initialization_answers_json",
|
|
804
976
|
{ path: answersPath },
|
|
805
977
|
);
|
|
978
|
+
if (!isRecord(fileAnswers)) {
|
|
979
|
+
throw new SmallPenError(
|
|
980
|
+
"invalid_initialization_answers_json",
|
|
981
|
+
"--answers must contain a JSON object keyed by question id",
|
|
982
|
+
{ path: answersPath },
|
|
983
|
+
);
|
|
984
|
+
}
|
|
806
985
|
}
|
|
807
986
|
const answers = {
|
|
808
987
|
...(persisted.answers ?? {}),
|
|
809
988
|
...fileAnswers,
|
|
810
989
|
...answerArguments(args),
|
|
811
990
|
};
|
|
812
|
-
const locale = option(args, "--locale") ??
|
|
991
|
+
const locale = option(args, "--locale") ?? defaultLocale();
|
|
813
992
|
let state = createInitializationState(answers, {
|
|
814
993
|
locale,
|
|
815
994
|
statePath,
|
|
@@ -832,7 +1011,22 @@ async function initializeCommand(workspacePath, args) {
|
|
|
832
1011
|
if (state.status === "needs_input") {
|
|
833
1012
|
state.nextQuestion.continuation.args[1] = resolve(workspacePath);
|
|
834
1013
|
state.nextQuestion.continuation.args.push("--locale", locale);
|
|
835
|
-
return {
|
|
1014
|
+
return {
|
|
1015
|
+
...state,
|
|
1016
|
+
answersTemplate:
|
|
1017
|
+
"smallpen schema init prints every question and a complete answers file for --answers",
|
|
1018
|
+
// Without --state a call never reads earlier answers, so a chain of
|
|
1019
|
+
// single --answer calls would keep only the last one.
|
|
1020
|
+
...(explicitStatePath
|
|
1021
|
+
? {}
|
|
1022
|
+
: {
|
|
1023
|
+
stateNotice:
|
|
1024
|
+
"This call did not read earlier answers because --state was not given. " +
|
|
1025
|
+
"Pass --state with the statePath below on every call (as continuation.args does) " +
|
|
1026
|
+
"to collect answers one at a time, or answer everything at once with --answers FILE.",
|
|
1027
|
+
}),
|
|
1028
|
+
statePath,
|
|
1029
|
+
};
|
|
836
1030
|
}
|
|
837
1031
|
const confirmation = {
|
|
838
1032
|
args: [
|
|
@@ -862,7 +1056,160 @@ async function initializeCommand(workspacePath, args) {
|
|
|
862
1056
|
status: "initialized",
|
|
863
1057
|
}),
|
|
864
1058
|
);
|
|
865
|
-
return { ...initialized, statePath };
|
|
1059
|
+
return { ...initialized, ...(await initializedSummary(initialized)), statePath };
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
// The init reply names every id the first writes need (Packages, Screen,
|
|
1063
|
+
// root node, starter Tokens and Component Sets), so an Agent can start
|
|
1064
|
+
// without reading the new files.
|
|
1065
|
+
async function initializedSummary({ foundationPath, productPath }) {
|
|
1066
|
+
const foundation = await openPackage(foundationPath);
|
|
1067
|
+
const product = await openPackage(productPath);
|
|
1068
|
+
const screenEntry = product.manifest.entries.screens.find(
|
|
1069
|
+
(entry) => product.entries[entry].id === product.manifest.defaultScreenId,
|
|
1070
|
+
);
|
|
1071
|
+
const screen = product.entries[screenEntry];
|
|
1072
|
+
const presentation = screen.presentations.find(
|
|
1073
|
+
({ id }) => id === screen.basePresentationId,
|
|
1074
|
+
);
|
|
1075
|
+
const root = presentation.nodes[presentation.rootId];
|
|
1076
|
+
const packageSummary = (snapshot) => ({
|
|
1077
|
+
packageId: snapshot.manifest.packageId,
|
|
1078
|
+
path: snapshot.locator,
|
|
1079
|
+
revision: snapshot.revision,
|
|
1080
|
+
});
|
|
1081
|
+
return {
|
|
1082
|
+
packages: {
|
|
1083
|
+
foundation: packageSummary(foundation),
|
|
1084
|
+
product: packageSummary(product),
|
|
1085
|
+
},
|
|
1086
|
+
start: {
|
|
1087
|
+
contextAxes: [...foundation.domain.contextAxes.values()].map(
|
|
1088
|
+
({ defaultValue, id, values }) => ({
|
|
1089
|
+
defaultValue,
|
|
1090
|
+
id,
|
|
1091
|
+
values: values.map((value) => value.id),
|
|
1092
|
+
}),
|
|
1093
|
+
),
|
|
1094
|
+
presentationId: presentation.id,
|
|
1095
|
+
rootNode: { height: root.height, id: root.id, width: root.width },
|
|
1096
|
+
scenarioIds: [...product.domain.scenarios.keys()].sort(compareText),
|
|
1097
|
+
screenId: screen.id,
|
|
1098
|
+
},
|
|
1099
|
+
seeded: {
|
|
1100
|
+
componentSets: [...foundation.domain.componentSets.values()].map(
|
|
1101
|
+
({ axes, id, name, variants }) => ({
|
|
1102
|
+
axes: axes.map((axis) => axis.id),
|
|
1103
|
+
componentSetId: id,
|
|
1104
|
+
name,
|
|
1105
|
+
packageId: foundation.manifest.packageId,
|
|
1106
|
+
variants: variants.map((variant) => ({
|
|
1107
|
+
rootId: variant.rootId,
|
|
1108
|
+
selection: variant.selection,
|
|
1109
|
+
variantId: variant.id,
|
|
1110
|
+
})),
|
|
1111
|
+
}),
|
|
1112
|
+
),
|
|
1113
|
+
note:
|
|
1114
|
+
"Starter content. put-token or put-component-set with one of these ids replaces it " +
|
|
1115
|
+
"whole (no merge). Write Tokens and Component Sets to packages.foundation.path; " +
|
|
1116
|
+
"write Screens and nodes to packages.product.path.",
|
|
1117
|
+
tokens: [...foundation.domain.tokens.values()].map(
|
|
1118
|
+
({ filePath, id, path, rawValue, type }) => ({
|
|
1119
|
+
filePath,
|
|
1120
|
+
packageId: foundation.manifest.packageId,
|
|
1121
|
+
path,
|
|
1122
|
+
tokenId: id,
|
|
1123
|
+
type,
|
|
1124
|
+
value: rawValue,
|
|
1125
|
+
}),
|
|
1126
|
+
),
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// The renderer reports one font_render_fallback per text node. Collapse them
|
|
1132
|
+
// to one diagnostic per missing family that names the fonts it can draw and
|
|
1133
|
+
// the import-font command that adds the missing one.
|
|
1134
|
+
async function renderEvidence(product, options) {
|
|
1135
|
+
const bundle = await createEvidence(product, options);
|
|
1136
|
+
const fallbacks = new Map();
|
|
1137
|
+
const diagnostics = [];
|
|
1138
|
+
for (const diagnostic of bundle.render.diagnostics) {
|
|
1139
|
+
if (diagnostic.code !== "font_render_fallback") {
|
|
1140
|
+
diagnostics.push(diagnostic);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const requestedFont =
|
|
1144
|
+
diagnostic.details?.requestedFont ??
|
|
1145
|
+
/substituted .+ for (.+)$/.exec(diagnostic.message)?.[1] ??
|
|
1146
|
+
"<family>";
|
|
1147
|
+
let collapsed = fallbacks.get(requestedFont);
|
|
1148
|
+
if (!collapsed) {
|
|
1149
|
+
collapsed = { diagnostic, nodeIds: [], paths: [], requestedFont };
|
|
1150
|
+
fallbacks.set(requestedFont, collapsed);
|
|
1151
|
+
diagnostics.push(collapsed);
|
|
1152
|
+
}
|
|
1153
|
+
if (diagnostic.nodeId !== undefined) collapsed.nodeIds.push(diagnostic.nodeId);
|
|
1154
|
+
if (diagnostic.path !== undefined) collapsed.paths.push(diagnostic.path);
|
|
1155
|
+
}
|
|
1156
|
+
if (fallbacks.size === 0) return bundle;
|
|
1157
|
+
const imported = [
|
|
1158
|
+
...new Set(
|
|
1159
|
+
[options.foundation, ...(options.libraries ?? []), product]
|
|
1160
|
+
.filter(Boolean)
|
|
1161
|
+
.flatMap((snapshot) =>
|
|
1162
|
+
snapshot.manifest.entries.assets.flatMap((entry) =>
|
|
1163
|
+
(snapshot.entries[entry].fonts ?? []).map(({ family }) => family),
|
|
1164
|
+
),
|
|
1165
|
+
),
|
|
1166
|
+
),
|
|
1167
|
+
].sort(compareText);
|
|
1168
|
+
const availableFonts = [
|
|
1169
|
+
"Source Sans Pro (bundled)",
|
|
1170
|
+
...imported.map((family) => `${family} (imported)`),
|
|
1171
|
+
];
|
|
1172
|
+
const collapsedEntries = new Set(fallbacks.values());
|
|
1173
|
+
const explained = diagnostics.map((entry) => {
|
|
1174
|
+
if (!collapsedEntries.has(entry)) return entry;
|
|
1175
|
+
const { requestedFont } = entry;
|
|
1176
|
+
return {
|
|
1177
|
+
availableFonts,
|
|
1178
|
+
code: entry.diagnostic.code,
|
|
1179
|
+
details: {
|
|
1180
|
+
...entry.diagnostic.details,
|
|
1181
|
+
requestedFont,
|
|
1182
|
+
substituteFont: entry.diagnostic.details?.substituteFont ?? "Source Sans Pro",
|
|
1183
|
+
},
|
|
1184
|
+
message:
|
|
1185
|
+
`${entry.diagnostic.message} on ${entry.nodeIds.length} text node(s): ` +
|
|
1186
|
+
`${requestedFont} is neither bundled nor imported. Available fonts: ` +
|
|
1187
|
+
`${availableFonts.join(", ")}. Import ${requestedFont} with smallpen import-font ` +
|
|
1188
|
+
"(once per weight/style; see nextOperations), or use an available family.",
|
|
1189
|
+
nextOperations: [
|
|
1190
|
+
{
|
|
1191
|
+
argv: [
|
|
1192
|
+
"import-font",
|
|
1193
|
+
product.locator,
|
|
1194
|
+
"--file",
|
|
1195
|
+
`<${requestedFont} .ttf|.otf|.woff file>`,
|
|
1196
|
+
"--family",
|
|
1197
|
+
requestedFont,
|
|
1198
|
+
"--weight",
|
|
1199
|
+
"<400|700|...>",
|
|
1200
|
+
"--json",
|
|
1201
|
+
],
|
|
1202
|
+
operation: "smallpen.import-font",
|
|
1203
|
+
},
|
|
1204
|
+
],
|
|
1205
|
+
nodeIds: entry.nodeIds,
|
|
1206
|
+
paths: entry.paths,
|
|
1207
|
+
requestedFont,
|
|
1208
|
+
};
|
|
1209
|
+
});
|
|
1210
|
+
bundle.render.diagnostics = explained;
|
|
1211
|
+
bundle.evidence.diagnostics = explained;
|
|
1212
|
+
return bundle;
|
|
866
1213
|
}
|
|
867
1214
|
|
|
868
1215
|
async function deliverImage(
|
|
@@ -900,7 +1247,7 @@ async function renderCommand(packagePath, args, evidenceOnly) {
|
|
|
900
1247
|
}
|
|
901
1248
|
const workspace = await tokenWorkspace(packagePath);
|
|
902
1249
|
const { foundation, libraries, product } = workspace;
|
|
903
|
-
const bundle = await
|
|
1250
|
+
const bundle = await renderEvidence(product, {
|
|
904
1251
|
foundation,
|
|
905
1252
|
libraries,
|
|
906
1253
|
scale: numberOption(args, "--scale", 1),
|
|
@@ -950,13 +1297,13 @@ async function inspectViewCommand(packagePath, args) {
|
|
|
950
1297
|
const semantic = readDesignView(product, {
|
|
951
1298
|
foundation,
|
|
952
1299
|
libraries,
|
|
953
|
-
locale: option(args, "--locale") ??
|
|
1300
|
+
locale: option(args, "--locale") ?? defaultLocale(),
|
|
954
1301
|
selector: { ...selector, viewFormat: "semantic" },
|
|
955
1302
|
});
|
|
956
1303
|
const wireframe = readDesignView(product, {
|
|
957
1304
|
foundation,
|
|
958
1305
|
libraries,
|
|
959
|
-
locale: option(args, "--locale") ??
|
|
1306
|
+
locale: option(args, "--locale") ?? defaultLocale(),
|
|
960
1307
|
selector: { ...selector, viewFormat: "wireframe" },
|
|
961
1308
|
});
|
|
962
1309
|
const context = semantic.selection.context;
|
|
@@ -971,7 +1318,7 @@ async function inspectViewCommand(packagePath, args) {
|
|
|
971
1318
|
wireframe: wireframe.result,
|
|
972
1319
|
};
|
|
973
1320
|
if (flag(args, "--include-image")) {
|
|
974
|
-
const bundle = await
|
|
1321
|
+
const bundle = await renderEvidence(product, {
|
|
975
1322
|
foundation,
|
|
976
1323
|
libraries,
|
|
977
1324
|
scale: numberOption(args, "--scale", 1),
|
|
@@ -996,7 +1343,7 @@ async function renderMatrixCommand(packagePath, args) {
|
|
|
996
1343
|
);
|
|
997
1344
|
}
|
|
998
1345
|
const contexts = parseJson(
|
|
999
|
-
await
|
|
1346
|
+
await readInputFile(contextsPath, "--contexts", "utf8"),
|
|
1000
1347
|
"invalid_render_contexts_json",
|
|
1001
1348
|
{ path: contextsPath },
|
|
1002
1349
|
);
|
|
@@ -1025,7 +1372,7 @@ async function renderMatrixCommand(packagePath, args) {
|
|
|
1025
1372
|
},
|
|
1026
1373
|
);
|
|
1027
1374
|
}
|
|
1028
|
-
const bundle = await
|
|
1375
|
+
const bundle = await renderEvidence(product, {
|
|
1029
1376
|
foundation,
|
|
1030
1377
|
libraries,
|
|
1031
1378
|
scale,
|
|
@@ -1096,7 +1443,7 @@ function tokenImpact(snapshot, tokenId, packageId) {
|
|
|
1096
1443
|
}
|
|
1097
1444
|
}
|
|
1098
1445
|
return locations.sort((left, right) =>
|
|
1099
|
-
JSON.stringify(left)
|
|
1446
|
+
compareText(JSON.stringify(left), JSON.stringify(right)),
|
|
1100
1447
|
);
|
|
1101
1448
|
}
|
|
1102
1449
|
|
|
@@ -1130,7 +1477,7 @@ function componentDetails(snapshot, componentId) {
|
|
|
1130
1477
|
scenario.target.component.assetId === componentId,
|
|
1131
1478
|
)
|
|
1132
1479
|
.map(({ id, name, target }) => ({ id, name, target }))
|
|
1133
|
-
.sort((left, right) => left.id
|
|
1480
|
+
.sort((left, right) => compareText(left.id, right.id)),
|
|
1134
1481
|
};
|
|
1135
1482
|
}
|
|
1136
1483
|
|
|
@@ -1240,7 +1587,7 @@ async function repairOperations(resolution, conflict, action, args) {
|
|
|
1240
1587
|
);
|
|
1241
1588
|
}
|
|
1242
1589
|
const asset = parseJson(
|
|
1243
|
-
await
|
|
1590
|
+
await readInputFile(assetPath, "--asset", "utf8"),
|
|
1244
1591
|
"invalid_recreated_asset_json",
|
|
1245
1592
|
{ path: assetPath },
|
|
1246
1593
|
);
|
|
@@ -1315,7 +1662,11 @@ function insertionAdvice(owner, product, component) {
|
|
|
1315
1662
|
const instanceNode = {
|
|
1316
1663
|
children: [],
|
|
1317
1664
|
height: dimensions.height,
|
|
1318
|
-
|
|
1665
|
+
// Derived from the revision so a repeated read returns the same advice.
|
|
1666
|
+
id: `node_instance_${createHash("sha256")
|
|
1667
|
+
.update(`${product.revision}\0${component.packageId}\0${component.id}`)
|
|
1668
|
+
.digest("hex")
|
|
1669
|
+
.slice(0, 8)}`,
|
|
1319
1670
|
instance: {
|
|
1320
1671
|
component: { assetId: component.id, packageId: component.packageId },
|
|
1321
1672
|
variant,
|
|
@@ -1532,6 +1883,25 @@ async function main(args) {
|
|
|
1532
1883
|
else process.stdout.write(`${version}\n`);
|
|
1533
1884
|
return;
|
|
1534
1885
|
}
|
|
1886
|
+
if (command === "schema") {
|
|
1887
|
+
if (flag(args, "--help") || flag(args, "-h")) {
|
|
1888
|
+
printHelp("schema");
|
|
1889
|
+
return;
|
|
1890
|
+
}
|
|
1891
|
+
const positional = args.slice(1).filter((value) => !value.startsWith("-"));
|
|
1892
|
+
validateCommandArguments("schema", ["schema", "", ...args.slice(1).filter(
|
|
1893
|
+
(value) => value.startsWith("-"),
|
|
1894
|
+
)]);
|
|
1895
|
+
if (positional.length > 2) {
|
|
1896
|
+
throw new SmallPenError(
|
|
1897
|
+
"unexpected_argument",
|
|
1898
|
+
`Unexpected positional argument: ${positional[2]}`,
|
|
1899
|
+
{ argument: positional[2], command },
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
printJson(schemaTopic(positional[0], positional[1]));
|
|
1903
|
+
return;
|
|
1904
|
+
}
|
|
1535
1905
|
if (!COMMAND_NAMES.includes(command)) {
|
|
1536
1906
|
throw new SmallPenError("unknown_command", `Unknown command: ${command}`, {
|
|
1537
1907
|
validCommands: COMMAND_NAMES,
|
|
@@ -1566,7 +1936,7 @@ async function main(args) {
|
|
|
1566
1936
|
"import-draft requires --input FILE",
|
|
1567
1937
|
);
|
|
1568
1938
|
}
|
|
1569
|
-
const input = await
|
|
1939
|
+
const input = await readInputFile(inputPath, "--input");
|
|
1570
1940
|
printJson(
|
|
1571
1941
|
await importDraft({
|
|
1572
1942
|
...(kind === "figma"
|
|
@@ -1588,7 +1958,7 @@ async function main(args) {
|
|
|
1588
1958
|
);
|
|
1589
1959
|
}
|
|
1590
1960
|
const document = parseJson(
|
|
1591
|
-
await
|
|
1961
|
+
await readInputFile(inputPath, "--input", "utf8"),
|
|
1592
1962
|
"invalid_import_json",
|
|
1593
1963
|
{ path: inputPath },
|
|
1594
1964
|
);
|
|
@@ -1643,7 +2013,7 @@ async function main(args) {
|
|
|
1643
2013
|
);
|
|
1644
2014
|
}
|
|
1645
2015
|
const selections = parseJson(
|
|
1646
|
-
await
|
|
2016
|
+
await readInputFile(selectionsPath, "--selections", "utf8"),
|
|
1647
2017
|
"invalid_draft_selections_json",
|
|
1648
2018
|
{ path: selectionsPath },
|
|
1649
2019
|
);
|
|
@@ -1695,7 +2065,7 @@ async function main(args) {
|
|
|
1695
2065
|
const offset = integerOption(args, "--offset", 0);
|
|
1696
2066
|
const limit = integerOption(args, "--limit", 100, 100);
|
|
1697
2067
|
const all = listDomain(snapshot, kind).sort((left, right) =>
|
|
1698
|
-
String(left.item.id ?? "")
|
|
2068
|
+
compareText(String(left.item.id ?? ""), String(right.item.id ?? "")),
|
|
1699
2069
|
);
|
|
1700
2070
|
printJson({
|
|
1701
2071
|
items: all.slice(offset, offset + limit),
|
|
@@ -1717,7 +2087,7 @@ async function main(args) {
|
|
|
1717
2087
|
printJson({
|
|
1718
2088
|
context,
|
|
1719
2089
|
items: listEffectiveTokens(product, { context, foundation, libraries }),
|
|
1720
|
-
labels:
|
|
2090
|
+
labels: tokenLabels(option(args, "--locale") ?? defaultLocale()),
|
|
1721
2091
|
packageId: product.manifest.packageId,
|
|
1722
2092
|
...workspaceRevisions(product, foundation, libraries),
|
|
1723
2093
|
});
|
|
@@ -1789,14 +2159,14 @@ async function main(args) {
|
|
|
1789
2159
|
foundation,
|
|
1790
2160
|
libraries,
|
|
1791
2161
|
limit: integerOption(args, "--limit", 20, 100),
|
|
1792
|
-
locale: option(args, "--locale") ??
|
|
2162
|
+
locale: option(args, "--locale") ?? defaultLocale(),
|
|
1793
2163
|
offset: integerOption(args, "--offset", 0),
|
|
1794
2164
|
selector: designSelector(args),
|
|
1795
2165
|
});
|
|
1796
2166
|
if (command === "discover") {
|
|
1797
2167
|
printJson(read.discovery);
|
|
1798
2168
|
} else if (read.format === "screenshot") {
|
|
1799
|
-
const bundle = await
|
|
2169
|
+
const bundle = await renderEvidence(product, {
|
|
1800
2170
|
foundation,
|
|
1801
2171
|
libraries,
|
|
1802
2172
|
scale: 1,
|
|
@@ -2013,10 +2383,17 @@ async function main(args) {
|
|
|
2013
2383
|
);
|
|
2014
2384
|
}
|
|
2015
2385
|
const batch = parseJson(
|
|
2016
|
-
await
|
|
2386
|
+
await readInputFile(batchPath, "--batch", "utf8"),
|
|
2017
2387
|
"invalid_batch_json",
|
|
2018
2388
|
{ path: batchPath },
|
|
2019
2389
|
);
|
|
2390
|
+
if (!isRecord(batch)) {
|
|
2391
|
+
throw new SmallPenError(
|
|
2392
|
+
"invalid_batch",
|
|
2393
|
+
"Operation Batch must be a JSON object",
|
|
2394
|
+
{ path: batchPath },
|
|
2395
|
+
);
|
|
2396
|
+
}
|
|
2020
2397
|
await checkExternalComponentConsumers(packagePath, batch.operations ?? []);
|
|
2021
2398
|
printJson(await applyBatch(packagePath, batch, args));
|
|
2022
2399
|
return;
|
|
@@ -2030,7 +2407,7 @@ async function main(args) {
|
|
|
2030
2407
|
);
|
|
2031
2408
|
}
|
|
2032
2409
|
const intent = parseJson(
|
|
2033
|
-
await
|
|
2410
|
+
await readInputFile(intentPath, "--intent", "utf8"),
|
|
2034
2411
|
"invalid_flow_intent_json",
|
|
2035
2412
|
{ path: intentPath },
|
|
2036
2413
|
);
|
|
@@ -2073,14 +2450,43 @@ async function main(args) {
|
|
|
2073
2450
|
const parentId = intent.parentId === undefined
|
|
2074
2451
|
? presentation.rootId
|
|
2075
2452
|
: intent.parentId;
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2453
|
+
// overrides on an INSTANCE intent node compile to set-instance-override
|
|
2454
|
+
// operations after the node is added, in the same atomic batch, so batch
|
|
2455
|
+
// preparation checks each override path and value against the component.
|
|
2456
|
+
const overrideOperations = [];
|
|
2457
|
+
const operations = intent.nodes.map((intentNode, index) => {
|
|
2458
|
+
let node = intentNode;
|
|
2459
|
+
if (isRecord(intentNode) && Object.hasOwn(intentNode, "overrides")) {
|
|
2460
|
+
const { overrides, ...rest } = intentNode;
|
|
2461
|
+
if (intentNode.type !== "INSTANCE" || !isRecord(overrides)) {
|
|
2462
|
+
throw new SmallPenError(
|
|
2463
|
+
"invalid_flow_intent",
|
|
2464
|
+
'overrides is only allowed on INSTANCE intent nodes, as {"<sourceNodeId>:<field>": value}',
|
|
2465
|
+
{ nodeId: intentNode.id, nodeIndex: index },
|
|
2466
|
+
);
|
|
2467
|
+
}
|
|
2468
|
+
node = rest;
|
|
2469
|
+
for (const [overridePath, value] of Object.entries(overrides)) {
|
|
2470
|
+
overrideOperations.push({
|
|
2471
|
+
nodeId: intentNode.id,
|
|
2472
|
+
overridePath,
|
|
2473
|
+
presentationId,
|
|
2474
|
+
screenId: intent.screenId,
|
|
2475
|
+
type: "set-instance-override",
|
|
2476
|
+
value,
|
|
2477
|
+
});
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
return {
|
|
2481
|
+
index: intent.index === undefined ? undefined : intent.index + index,
|
|
2482
|
+
node,
|
|
2483
|
+
parentId,
|
|
2484
|
+
presentationId,
|
|
2485
|
+
screenId: intent.screenId,
|
|
2486
|
+
type: "add-presentation-node",
|
|
2487
|
+
};
|
|
2488
|
+
});
|
|
2489
|
+
operations.push(...overrideOperations);
|
|
2084
2490
|
printJson(
|
|
2085
2491
|
await applyBatch(packagePath, {
|
|
2086
2492
|
baseRevision: snapshot.revision,
|
|
@@ -2099,7 +2505,7 @@ async function main(args) {
|
|
|
2099
2505
|
);
|
|
2100
2506
|
}
|
|
2101
2507
|
const intent = parseJson(
|
|
2102
|
-
await
|
|
2508
|
+
await readInputFile(intentPath, "--intent", "utf8"),
|
|
2103
2509
|
"invalid_token_intent_json",
|
|
2104
2510
|
{ path: intentPath },
|
|
2105
2511
|
);
|
|
@@ -2120,14 +2526,14 @@ async function main(args) {
|
|
|
2120
2526
|
return;
|
|
2121
2527
|
}
|
|
2122
2528
|
if (command === "import-media") {
|
|
2123
|
-
const mediaPath = option(args, "--file")
|
|
2529
|
+
const mediaPath = option(args, "--file");
|
|
2124
2530
|
if (!mediaPath) {
|
|
2125
2531
|
throw new SmallPenError(
|
|
2126
2532
|
"missing_media_file",
|
|
2127
2533
|
"import-media requires --file MEDIA_FILE",
|
|
2128
2534
|
);
|
|
2129
2535
|
}
|
|
2130
|
-
const bytes = new Uint8Array(await
|
|
2536
|
+
const bytes = new Uint8Array(await readInputFile(mediaPath, "--file"));
|
|
2131
2537
|
const inspected = inspectMedia(bytes);
|
|
2132
2538
|
const mediaId = option(args, "--media-id") ?? `media_${randomUUID()}`;
|
|
2133
2539
|
const name = option(args, "--name") ?? mediaPath.split(/[\\/]/).pop();
|
|
@@ -2141,7 +2547,9 @@ async function main(args) {
|
|
|
2141
2547
|
width: inspected.width,
|
|
2142
2548
|
});
|
|
2143
2549
|
printJson({
|
|
2550
|
+
batchId: print.result.batchId,
|
|
2144
2551
|
descriptor: print.descriptor,
|
|
2552
|
+
inverseBatch: print.result.inverseBatch,
|
|
2145
2553
|
revision: print.result.revision,
|
|
2146
2554
|
});
|
|
2147
2555
|
return;
|
|
@@ -2155,11 +2563,17 @@ async function main(args) {
|
|
|
2155
2563
|
);
|
|
2156
2564
|
}
|
|
2157
2565
|
const result = await removeMedia(packagePath, mediaId);
|
|
2158
|
-
printJson({
|
|
2566
|
+
printJson({
|
|
2567
|
+
batchId: result.batchId,
|
|
2568
|
+
inverseBatch: result.inverseBatch,
|
|
2569
|
+
mediaId,
|
|
2570
|
+
removed: true,
|
|
2571
|
+
revision: result.revision,
|
|
2572
|
+
});
|
|
2159
2573
|
return;
|
|
2160
2574
|
}
|
|
2161
2575
|
if (command === "import-font") {
|
|
2162
|
-
const fontPath = option(args, "--file")
|
|
2576
|
+
const fontPath = option(args, "--file");
|
|
2163
2577
|
if (!fontPath) {
|
|
2164
2578
|
throw new SmallPenError(
|
|
2165
2579
|
"missing_font_file",
|
|
@@ -2173,7 +2587,7 @@ async function main(args) {
|
|
|
2173
2587
|
"import-font requires --family NAME",
|
|
2174
2588
|
);
|
|
2175
2589
|
}
|
|
2176
|
-
const bytes = new Uint8Array(await
|
|
2590
|
+
const bytes = new Uint8Array(await readInputFile(fontPath, "--file"));
|
|
2177
2591
|
const signatureTtf = bytes[0] === 0x00 && bytes[1] === 0x01;
|
|
2178
2592
|
const signatureOtf = bytes[0] === 0x4f && bytes[1] === 0x54;
|
|
2179
2593
|
const signatureWoff = bytes[0] === 0x77 && bytes[1] === 0x4f && bytes[2] === 0x46 && bytes[3] === 0x46;
|
|
@@ -2216,13 +2630,15 @@ async function main(args) {
|
|
|
2216
2630
|
files,
|
|
2217
2631
|
fontId,
|
|
2218
2632
|
id: variantId,
|
|
2219
|
-
name: option(args, "--name") ??
|
|
2633
|
+
name: option(args, "--name") ?? fontVariantName(weight, style),
|
|
2220
2634
|
style,
|
|
2221
2635
|
weight,
|
|
2222
2636
|
});
|
|
2223
2637
|
printJson({
|
|
2224
|
-
|
|
2638
|
+
batchId: result.batchId,
|
|
2225
2639
|
files: variant.files,
|
|
2640
|
+
fontId,
|
|
2641
|
+
inverseBatch: result.inverseBatch,
|
|
2226
2642
|
revision: result.revision,
|
|
2227
2643
|
variantId: variant.id,
|
|
2228
2644
|
});
|
|
@@ -2271,6 +2687,8 @@ async function main(args) {
|
|
|
2271
2687
|
cache: refreshed.remote.cache,
|
|
2272
2688
|
packageId: refreshed.manifest.packageId,
|
|
2273
2689
|
revision: refreshed.revision,
|
|
2690
|
+
// A failed fetch with a verified cache reports "stale": keep the reason.
|
|
2691
|
+
warning: refreshed.remote.warning,
|
|
2274
2692
|
},
|
|
2275
2693
|
before: before
|
|
2276
2694
|
? { packageId: before.manifest.packageId, revision: before.revision }
|
|
@@ -2282,20 +2700,34 @@ async function main(args) {
|
|
|
2282
2700
|
return;
|
|
2283
2701
|
}
|
|
2284
2702
|
if (command === "watch") {
|
|
2285
|
-
|
|
2703
|
+
// Validate before clamping: Number("abc") is NaN and would poll nonstop.
|
|
2704
|
+
const interval = Math.max(
|
|
2705
|
+
100,
|
|
2706
|
+
integerOption(args, "--interval", 500, 3_600_000),
|
|
2707
|
+
);
|
|
2286
2708
|
// Default is unlimited: watch without --max-events must keep observing
|
|
2287
2709
|
// (SP-041-A caught a fallback of 1 that exited after the first event).
|
|
2288
2710
|
const maxEvents = integerOption(args, "--max-events", Number.POSITIVE_INFINITY, 10_000);
|
|
2711
|
+
if (maxEvents === 0) {
|
|
2712
|
+
throw new SmallPenError(
|
|
2713
|
+
"invalid_integer_option",
|
|
2714
|
+
"--max-events requires an integer from 1 through 10000",
|
|
2715
|
+
{ maximum: 10_000, name: "--max-events", value: "0" },
|
|
2716
|
+
);
|
|
2717
|
+
}
|
|
2289
2718
|
const emit = (payload) =>
|
|
2290
2719
|
process.stdout.write(`${JSON.stringify(payload)}\n`);
|
|
2720
|
+
// The last emitted state: an unchanged invalid state is not repeated
|
|
2721
|
+
// every poll, and recovery reports the revision again even when it is
|
|
2722
|
+
// the one before the Package became invalid.
|
|
2291
2723
|
let previous;
|
|
2292
2724
|
let events = 0;
|
|
2293
2725
|
process.on("SIGINT", () => process.exit(0));
|
|
2294
2726
|
for (;;) {
|
|
2295
2727
|
try {
|
|
2296
2728
|
const snapshot = await openPackage(packagePath);
|
|
2297
|
-
if (snapshot.revision !== previous) {
|
|
2298
|
-
previous = snapshot.revision
|
|
2729
|
+
if (`revision:${snapshot.revision}` !== previous) {
|
|
2730
|
+
previous = `revision:${snapshot.revision}`;
|
|
2299
2731
|
events += 1;
|
|
2300
2732
|
emit({
|
|
2301
2733
|
event: "revision",
|
|
@@ -2305,13 +2737,17 @@ async function main(args) {
|
|
|
2305
2737
|
if (maxEvents !== undefined && events >= maxEvents) return;
|
|
2306
2738
|
}
|
|
2307
2739
|
} catch (error) {
|
|
2308
|
-
|
|
2309
|
-
emit({
|
|
2740
|
+
const invalid = {
|
|
2310
2741
|
code: error instanceof SmallPenError ? error.code : "internal_error",
|
|
2311
2742
|
event: "invalid",
|
|
2312
2743
|
message: error instanceof Error ? error.message : String(error),
|
|
2313
|
-
}
|
|
2314
|
-
if (
|
|
2744
|
+
};
|
|
2745
|
+
if (`invalid:${invalid.code}:${invalid.message}` !== previous) {
|
|
2746
|
+
previous = `invalid:${invalid.code}:${invalid.message}`;
|
|
2747
|
+
events += 1;
|
|
2748
|
+
emit(invalid);
|
|
2749
|
+
if (maxEvents !== undefined && events >= maxEvents) return;
|
|
2750
|
+
}
|
|
2315
2751
|
}
|
|
2316
2752
|
await delay(interval);
|
|
2317
2753
|
}
|
|
@@ -2386,7 +2822,7 @@ function errorDetails(error, args) {
|
|
|
2386
2822
|
}
|
|
2387
2823
|
if (!replaced) retry.push("--answer", `${details.questionId}=<JSON>`);
|
|
2388
2824
|
if (!retry.includes("--json")) retry.push("--json");
|
|
2389
|
-
if (!retry.includes("--locale")) retry.push("--locale",
|
|
2825
|
+
if (!retry.includes("--locale")) retry.push("--locale", defaultLocale());
|
|
2390
2826
|
return {
|
|
2391
2827
|
...details,
|
|
2392
2828
|
nextOperations: [{ argv: retry, operation: "smallpen.init.answer" }],
|