@smallpen/cli 0.1.0-alpha.16 → 0.1.0-alpha.19
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/command-contract.mjs +1 -1
- package/bin/design-output.mjs +65 -2
- package/bin/help.mjs +9 -4
- package/bin/schema.mjs +18 -16
- package/bin/smallpen.mjs +54 -20
- package/bin/validation-runner.mjs +30 -10
- package/package.json +3 -3
package/bin/command-contract.mjs
CHANGED
|
@@ -165,7 +165,7 @@ const PARAMETERS = {
|
|
|
165
165
|
setField: parameter(
|
|
166
166
|
"--set",
|
|
167
167
|
"assignment",
|
|
168
|
-
'FIELD=VALUE as in page draw: width=320, fill={color.surface}, padding=[8,16], text="Save", props={"Style":"ghost"}; a JSON value or plain text',
|
|
168
|
+
'FIELD=VALUE as in page draw: width=320, fill={color.surface}, padding=[8,16], text="Save", props={"Style":"ghost"}; a JSON value or plain text. Conflicting values for one field, including --intent values, are rejected',
|
|
169
169
|
{ repeatable: true },
|
|
170
170
|
),
|
|
171
171
|
variant: parameter("--variant", "string", 'One variant, as "Style=secondary" (or just "secondary")'),
|
package/bin/design-output.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
asciiWireframe,
|
|
4
4
|
createSemanticTree,
|
|
5
5
|
defaultTokenThemeIds,
|
|
6
|
+
elementPath,
|
|
6
7
|
listTokenThemes,
|
|
7
8
|
layoutProjectionDiagnostics,
|
|
8
9
|
projectComponentVariant,
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
SmallPenError,
|
|
14
15
|
tokenLibraryOf,
|
|
15
16
|
} from "@smallpen/core";
|
|
16
|
-
import { inspectProjection } from "@smallpen/local-package";
|
|
17
|
+
import { inspectProjection, layoutSizingRules } from "@smallpen/local-package";
|
|
17
18
|
|
|
18
19
|
export async function resolveDesignLayout(workspace, design, options = {}) {
|
|
19
20
|
if (design.layoutSnapshot && (!options.raster || design.render?.bytes?.length)) return design;
|
|
@@ -642,6 +643,52 @@ export function platformHints(workspace, design) {
|
|
|
642
643
|
return hints;
|
|
643
644
|
}
|
|
644
645
|
|
|
646
|
+
const SIZING = { width: "layout-item-h-sizing", height: "layout-item-v-sizing" };
|
|
647
|
+
const LIMITS = { minWidth: "layout-item-min-w", maxWidth: "layout-item-max-w", minHeight: "layout-item-min-h", maxHeight: "layout-item-max-h" };
|
|
648
|
+
const layoutContainer = (node) => node?.layout === "flex" || node?.layout === "grid";
|
|
649
|
+
|
|
650
|
+
// Stored sizes the App cannot set: hug on an element without a row or
|
|
651
|
+
// column layout, fill without a layout parent, min / max on an axis that
|
|
652
|
+
// does not fill, and a container that hugs what a child fills (the App's
|
|
653
|
+
// own rule, fixSizing, would make it fixed). Hidden elements and elements
|
|
654
|
+
// inside copies count too: a copy may show what its component hides, and
|
|
655
|
+
// repeats inside copies are reported once, against the component.
|
|
656
|
+
export function appLayoutIssues(nodes, fixSizing) {
|
|
657
|
+
const issues = [];
|
|
658
|
+
const own = Object.keys(nodes);
|
|
659
|
+
const parentOf = new Map();
|
|
660
|
+
for (const [id, node] of Object.entries(nodes)) for (const child of node.children ?? []) parentOf.set(child, id);
|
|
661
|
+
const add = (nodeId, message, detail) => issues.push({ code: "layout_not_in_app", severity: "error", check: "app-layout", nodeId, message, ...detail });
|
|
662
|
+
for (const id of own) {
|
|
663
|
+
const node = nodes[id], parent = nodes[parentOf.get(id)];
|
|
664
|
+
for (const [axis, field] of Object.entries(SIZING)) {
|
|
665
|
+
if (node[field] === "auto" && !layoutContainer(node))
|
|
666
|
+
add(id, node.type === "TEXT"
|
|
667
|
+
? `${node.name}: a text cannot hug its ${axis} in the App; it hugs by ${axis === "width" ? "auto width" : "auto height"}. Run project repair --action text-sizing to store every such text the App's way (nothing moves)`
|
|
668
|
+
: `${node.name} hugs its ${axis} without a row or column layout; the App offers hug only to such containers. Give it a ${axis} or a layout`, { field: axis });
|
|
669
|
+
if (node[field] === "fill" && parent && !layoutContainer(parent))
|
|
670
|
+
add(id, `${node.name} fills its ${axis} but ${parent.name} has no row or column layout; the App offers fill only inside one. Give it a ${axis} or give ${parent.name} a layout`, { field: axis });
|
|
671
|
+
}
|
|
672
|
+
for (const [name, field] of Object.entries(LIMITS)) {
|
|
673
|
+
const axis = name.endsWith("Width") ? "width" : "height";
|
|
674
|
+
if (node[field] !== undefined && node[field] !== null && node[SIZING[axis]] !== "fill")
|
|
675
|
+
add(id, `${node.name} has ${name} ${node[field]} but its ${axis} does not fill; the App sets min and max sizes only on an axis that fills. Set ${axis} to fill or ${name}=null`, { field: name });
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const containers = own.filter((id) => nodes[id].layout === "flex");
|
|
679
|
+
const changes = containers.length ? fixSizing({ nodes, parents: containers, children: [] }) : {};
|
|
680
|
+
for (const [id, fields] of Object.entries(changes)) {
|
|
681
|
+
if (!own.includes(id)) continue;
|
|
682
|
+
for (const [field, axis] of [["layout-item-h-sizing", "width"], ["layout-item-v-sizing", "height"]]) {
|
|
683
|
+
if (!fields[field]) continue;
|
|
684
|
+
const fillers = (nodes[id].children ?? []).filter((child) => nodes[child]?.[field] === "fill");
|
|
685
|
+
const filling = fillers.map((child) => nodes[child].name);
|
|
686
|
+
add(id, `${nodes[id].name} hugs its ${axis} while ${filling.join(", ")} fill${filling.length === 1 ? "s" : ""} it; the App makes such a container fixed, and the filling element shrinks to nothing when drawn. Give ${nodes[id].name} a ${axis}, or let ${filling.join(", ")} hug`, { field: axis });
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return issues;
|
|
690
|
+
}
|
|
691
|
+
|
|
645
692
|
export async function validateDesign(workspace, design) {
|
|
646
693
|
design = await resolveDesignLayout(workspace, design);
|
|
647
694
|
if (design.layoutSnapshot.status !== "resolved") {
|
|
@@ -714,6 +761,19 @@ export async function validateDesign(workspace, design) {
|
|
|
714
761
|
measured: { width: item.width, height: item.height },
|
|
715
762
|
box: { width: node.width, height: node.height },
|
|
716
763
|
});
|
|
764
|
+
} else if (
|
|
765
|
+
// A wrapping box narrower than one word splits the word itself.
|
|
766
|
+
item.growType !== "auto-width" && item.longestPiece &&
|
|
767
|
+
item.longestPiece.width > node.width + Math.max(1, node.width * 0.02)
|
|
768
|
+
) {
|
|
769
|
+
issues.push({
|
|
770
|
+
code: "text_word_split",
|
|
771
|
+
severity: "warning",
|
|
772
|
+
nodeId: node.id,
|
|
773
|
+
message: `"${item.longestPiece.text}" is ${Math.ceil(item.longestPiece.width)}px wide but its box is ${Math.round(node.width)}px, so the word breaks across lines; give the text a width or min width, or let it hug its content (width=hug)`,
|
|
774
|
+
measured: { width: item.longestPiece.width, text: item.longestPiece.text },
|
|
775
|
+
box: { width: node.width, height: node.height },
|
|
776
|
+
});
|
|
717
777
|
}
|
|
718
778
|
}
|
|
719
779
|
for (const diagnostic of metrics.diagnostics)
|
|
@@ -880,8 +940,11 @@ export async function validateDesign(workspace, design) {
|
|
|
880
940
|
for (const child of children) walk(child, [...ancestors, node], semantic);
|
|
881
941
|
};
|
|
882
942
|
walk(design.tree.root, design.ancestors);
|
|
943
|
+
issues.push(...appLayoutIssues(design.authoredProjection?.nodes ?? nodes, await layoutSizingRules()));
|
|
883
944
|
for (const issue of issues) {
|
|
884
945
|
if (issue.nodeId && places.has(issue.nodeId)) issue.where = where(issue.nodeId);
|
|
946
|
+
// A hidden element is not drawn, so it is named by its path.
|
|
947
|
+
else if (issue.nodeId && nodes[issue.nodeId]) issue.where = elementPath(nodes, design.projection.rootId, issue.nodeId).replaceAll(" / ", " › ");
|
|
885
948
|
if (issue.otherNodeId && places.has(issue.otherNodeId)) issue.otherWhere = where(issue.otherNodeId);
|
|
886
949
|
const text = nodes[issue.nodeId]?.type === "TEXT" ? nodes[issue.nodeId].text : undefined;
|
|
887
950
|
if (text) issue.text = text.length > 40 ? `${text.slice(0, 39)}…` : text;
|
|
@@ -902,7 +965,7 @@ export async function validateDesign(workspace, design) {
|
|
|
902
965
|
continue;
|
|
903
966
|
}
|
|
904
967
|
// Copies differ in their text overrides, not in the definition at fault.
|
|
905
|
-
const key = [issue.code, segments.at(-1), issue.ratio ?? ""].join("\0");
|
|
968
|
+
const key = [issue.code, segments.at(-1), issue.ratio ?? "", issue.field ?? ""].join("\0");
|
|
906
969
|
const first = byDefinition.get(key);
|
|
907
970
|
if (!first) {
|
|
908
971
|
const entry = { ...issue, occurrences: 1 };
|
package/bin/help.mjs
CHANGED
|
@@ -325,9 +325,12 @@ Output:
|
|
|
325
325
|
smallpen asset font import <package.smallpen> --file FONT_FILE --family NAME
|
|
326
326
|
[--weight 400] [--style normal] [--name NAME] [--json]
|
|
327
327
|
|
|
328
|
-
Public Font entry for an existing Product. TTF and
|
|
329
|
-
|
|
330
|
-
|
|
328
|
+
Public Font entry for an existing Product. TTF, OTF and WOFF are validated
|
|
329
|
+
and stored as one original file per weight/style. App and CLI share native
|
|
330
|
+
font loading; browser WOFF and native SFNT are derived on demand in a bounded
|
|
331
|
+
system cache outside the Package. Reading or rendering does not change the
|
|
332
|
+
Package revision or add derived blobs. Existing multi-format fonts still open.
|
|
333
|
+
A valid WOFF2 reaches an explicit
|
|
331
334
|
unsupported_font_conversion boundary (no WOFF2 decoder is bundled); corrupt
|
|
332
335
|
fonts fail invalid_font_blob. Either way nothing is written. Success returns
|
|
333
336
|
the family, files, revision, batchId, and the exact inverseBatch. Use the
|
|
@@ -429,7 +432,7 @@ Purpose:
|
|
|
429
432
|
|
|
430
433
|
Batch contract:
|
|
431
434
|
{"baseRevision":"...","batchId":"unique-id","operations":[...]}
|
|
432
|
-
baseRevision is
|
|
435
|
+
baseRevision is the revision from the last write reply or smallpen inspect PACKAGE --json.
|
|
433
436
|
The whole candidate validates and commits atomically. Success returns revision,
|
|
434
437
|
affected stable IDs/files, a revision-guarded reverse edit, and changed
|
|
435
438
|
(false, with noChange, when the batch leaves the Package as it was).
|
|
@@ -553,6 +556,8 @@ Actions:
|
|
|
553
556
|
remove-dependent-usage no additional arguments
|
|
554
557
|
choose-foundation --foundation PATH
|
|
555
558
|
recreate-product-asset --asset-kind token|component --asset FILE
|
|
559
|
+
text-sizing no conflicts needed: store texts written as hug the
|
|
560
|
+
App's way (fixed, sized by their grow type); nothing moves
|
|
556
561
|
|
|
557
562
|
Without --action, returns all typed conflicts and choices. Each action is checked
|
|
558
563
|
against the selected conflict and committed through one atomic Operation Batch.
|
package/bin/schema.mjs
CHANGED
|
@@ -39,7 +39,7 @@ const FORMAT = SMALLPEN_FORMAT_CAPABILITIES;
|
|
|
39
39
|
const OPERATION_TYPES = FORMAT.canonicalWrite.operationTypes;
|
|
40
40
|
// The default `smallpen init` layout: one self-contained Package.
|
|
41
41
|
const PACKAGE = "acme/acme.smallpen";
|
|
42
|
-
const REVISION = "<
|
|
42
|
+
const REVISION = "<revision from the last write reply or smallpen project show PACKAGE --json>";
|
|
43
43
|
|
|
44
44
|
export const PACKAGE_ROLES = Object.freeze({
|
|
45
45
|
foundation:
|
|
@@ -157,7 +157,7 @@ export const WORKED_EXAMPLE = Object.freeze([
|
|
|
157
157
|
"canvas-v1",
|
|
158
158
|
),
|
|
159
159
|
},
|
|
160
|
-
note: "Copy the revision from the previous reply (or
|
|
160
|
+
note: "Copy the revision from the previous reply (or project show PACKAGE --json) into baseRevision before applying the batch. Existing IDs replace whole definitions; use update for partial edits.",
|
|
161
161
|
},
|
|
162
162
|
{
|
|
163
163
|
title: "Add text to that canvas",
|
|
@@ -272,7 +272,7 @@ function operationDetail(type) {
|
|
|
272
272
|
...(schema.oneOf ? { requiresOneOf: schema.oneOf } : {}),
|
|
273
273
|
unknownFields: "rejected with unknown_operation_field",
|
|
274
274
|
example: batchFor([schema.example], `example-${type}`),
|
|
275
|
-
run: `smallpen apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
|
|
275
|
+
run: `smallpen advanced apply <${schema.package === "foundation" ? "foundation" : "product"}.smallpen> --batch BATCH.json --dry-run --json`,
|
|
276
276
|
};
|
|
277
277
|
}
|
|
278
278
|
|
|
@@ -720,18 +720,16 @@ function batchTopic() {
|
|
|
720
720
|
"Every write is one atomic Operation Batch. Nothing is written unless every operation is valid.",
|
|
721
721
|
batch: {
|
|
722
722
|
baseRevision:
|
|
723
|
-
"
|
|
724
|
-
batchId: "1-192 characters [A-Za-z0-9_.-], new for every write",
|
|
723
|
+
"revision from the last write reply or smallpen project show PACKAGE --json; a stale value is rejected",
|
|
724
|
+
batchId: "1-192 characters [A-Za-z0-9_.-], new for every write. Batch IDs are remembered per package path: a fresh copy put at the same path keeps that path's IDs, so rehearse in a copy at a new path to reuse them on the real package",
|
|
725
725
|
operations: "array; see: smallpen schema operations",
|
|
726
726
|
},
|
|
727
727
|
packages: PACKAGE_ROLES,
|
|
728
728
|
commands: {
|
|
729
729
|
apply:
|
|
730
|
-
"smallpen apply PACKAGE --batch BATCH.json [--dry-run] [--explain] [--diff]",
|
|
731
|
-
|
|
732
|
-
"
|
|
733
|
-
page: "smallpen page PRODUCT --intent {screenId, presentationId?, parentId?, index?, nodes:[node, ...]}: one add-presentation-node per node, all siblings under parentId (default: the root); overrides on an INSTANCE node become set-instance-override operations",
|
|
734
|
-
flow: "smallpen flow PRODUCT --intent: same as page, for flowchart nodes",
|
|
730
|
+
"smallpen advanced apply PACKAGE --batch BATCH.json [--dry-run] [--explain] [--diff]",
|
|
731
|
+
named:
|
|
732
|
+
"page, component, token, theme, flow, asset and canvas commands build the batch from names, with baseRevision and batchId filled in",
|
|
735
733
|
},
|
|
736
734
|
result:
|
|
737
735
|
"batchId, revision, changedFiles, affectedIds, changed (false for no-op or replay), alreadyApplied, dryRun and summary. changeReportPath records exact before/after values. reverseEdit/inverseBatchPath describe an explicit new apply write with its own identity and a fixed baseRevision, not history rollback. After later edits, read current fields and construct a new scoped edit; do not force an old snapshot by changing its baseRevision. --inverse-out FILE chooses a durable file. --full includes exact details. Reuse batchId only for identical retries; ledger retains at most 500 identities.",
|
|
@@ -764,8 +762,8 @@ function setTopic(command) {
|
|
|
764
762
|
return {
|
|
765
763
|
command,
|
|
766
764
|
fields: {
|
|
767
|
-
set: "FIELD=VALUE, repeatable; VALUE is JSON (320, [8,16], {\"Style\":\"ghost\"}, true) or plain text; {token.path} binds a Token",
|
|
768
|
-
element: "fields: width, height (number, px, Token, fill, hug), fill, stroke, radius, opacity, shadow, visible, rotation, margin, min/max sizes; texts: text, color, font, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, textTransform, textDecoration, textAlign; containers: layout (row|column|none), gap, padding, align, justify",
|
|
765
|
+
set: "FIELD=VALUE, repeatable; VALUE is JSON (320, [8,16], {\"Style\":\"ghost\"}, true) or plain text; {token.path} binds a Token. Conflicting values for one field, including --intent values, are rejected",
|
|
766
|
+
element: "fields: width, height (number, px, Token, fill, hug), fill, stroke, radius, opacity, shadow, visible, rotation, margin, min/max sizes; texts: text, color, font, fontFamily, fontSize, fontWeight, lineHeight, letterSpacing, textTransform, textDecoration, textAlign; containers: layout (row|column|none), gap, padding, align (start|center|end|stretch), justify (start|center|end|space-between). gap, padding, align and justify need a row or column on the target",
|
|
769
767
|
copy: "a copy of a component: props, text {\"Element\": \"text\"}, set {\"Element.field\": value}, width/height (numbers, px, Tokens, hug, or fill in a row/column parent), visible, opacity. Sizes inside set (Element.width/height) take numbers or Tokens",
|
|
770
768
|
itself: "without --element: the page version (or the variant) itself, for example its width and height",
|
|
771
769
|
},
|
|
@@ -779,7 +777,11 @@ function setTopic(command) {
|
|
|
779
777
|
notes: [
|
|
780
778
|
"Only the named fields change; everything else, links included, stays. Texts are measured again and containers that hug grow or shrink, as page draw does.",
|
|
781
779
|
"Versions and variants often differ on purpose: an element (or the page or component itself) in several of them needs --platform (--variant) to name one, or --all to change every one; the refusal lists where it is.",
|
|
782
|
-
"A value written over a Token binding replaces the binding.",
|
|
780
|
+
"A value written over a Token binding replaces the binding, on an element and on a copy (visible, opacity) alike.",
|
|
781
|
+
"fill=none or stroke=none removes the paint; a color is \"#RRGGBB\", \"#RRGGBBAA\" (the last two digits are its opacity), rgb() or a CSS color name.",
|
|
782
|
+
"--variant names some or all properties (\"State=default\"); every variant it names is changed, and several need --all like an element in several variants.",
|
|
783
|
+
"Sizes follow the App: hug only on a row/column container or a text, min and max sizes only on an axis that fills; a container that hugs what a child fills becomes fixed, as the App makes it. Anything else is refused with what to write instead, and validate reports stored sizes the App cannot set (layout_not_in_app).",
|
|
784
|
+
"An element path lists parents in order (\"Content / Heading\"); when several elements match, the one directly inside those parents wins.",
|
|
783
785
|
],
|
|
784
786
|
};
|
|
785
787
|
}
|
|
@@ -805,10 +807,10 @@ const TOPICS = {
|
|
|
805
807
|
layout: "row | column | none; in none the CLI places children so they do not overlap: give place (\"below Header\", {rightOf: \"Sidebar\", gap: 24}) or nothing, x/y only to pin one",
|
|
806
808
|
"gap / padding": "number or {token}; padding takes 1, 2 or 4 values",
|
|
807
809
|
"align / justify": "start | center | end (align also stretch; justify also space-between)",
|
|
808
|
-
"width / height": 'number, "fill" (or "100%") or "hug"
|
|
810
|
+
"width / height": 'number, "fill" (or "100%") or "hug", as the App offers them: fill only inside a row/column parent; hug only on a row/column container, a copy of one, or a text (stored as the App\'s auto width / auto height; a text that hugs its width also hugs its height). Texts hug by default; the only text in a row with a set width fills it',
|
|
809
811
|
"fill / stroke / radius / opacity / shadow": 'value or {token}; stroke is a color or {color, width, style}, width one value or four sides [top, right, bottom, left]; radius is one value or four corners [top-left, top-right, bottom-right, bottom-left]',
|
|
810
812
|
margin: "number or {token}; 1, 2 or 4 values, like padding",
|
|
811
|
-
"minWidth / maxWidth / minHeight / maxHeight": "number or {token}",
|
|
813
|
+
"minWidth / maxWidth / minHeight / maxHeight": "number or {token}; only on an axis that is fill, as in the App (invalid_size_limit otherwise)",
|
|
812
814
|
"letterSpacing / lineHeight / textTransform / textDecoration": "text only; value or {token} (textTransform: uppercase|lowercase|capitalize|none, textDecoration: underline|line-through|none)",
|
|
813
815
|
rotation: "degrees or {token}",
|
|
814
816
|
visible: "false hides it (for example per platform in a variant), or {flag} for a boolean Token",
|
|
@@ -856,7 +858,7 @@ const TOPICS = {
|
|
|
856
858
|
],
|
|
857
859
|
},
|
|
858
860
|
notes: [
|
|
859
|
-
"Drawing a page again redraws it; elements drawn again under the same names keep their ids and interactions.",
|
|
861
|
+
"Drawing a page again redraws it; elements drawn again under the same names keep their ids and interactions. A copy of the same component drawn again also keeps a Token-bound or fill width and height unless the element gives width or height (\"hug\" lets it follow its content again).",
|
|
860
862
|
"An instance whose new text is longer than its slot grows by the measured difference.",
|
|
861
863
|
"Link elements with flow link --from \"Page / Element\" --to Page.",
|
|
862
864
|
"Move an element later with page move --page Page --element Name --direction up|down|left|right [--steps N]; the CLI picks the coordinates.",
|
package/bin/smallpen.mjs
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
flowStartIntent,
|
|
26
26
|
pageDrawOperation,
|
|
27
27
|
componentSetOperation,
|
|
28
|
+
textSizingRepairOperations,
|
|
28
29
|
pageSetOperation,
|
|
29
30
|
themeAddIntents,
|
|
30
31
|
themeDefaultIntent,
|
|
@@ -115,10 +116,10 @@ import {
|
|
|
115
116
|
openPackage,
|
|
116
117
|
readBatchHistory,
|
|
117
118
|
openRemoteLibrary,
|
|
119
|
+
layoutSizingRules,
|
|
118
120
|
measureProjectionText,
|
|
119
121
|
replayRecordedBatch,
|
|
120
122
|
resolveWorkspace,
|
|
121
|
-
sfntToWoff,
|
|
122
123
|
} from "@smallpen/local-package";
|
|
123
124
|
|
|
124
125
|
import { COMMAND_NAMES, commandGuidance, printHelp } from "./help.mjs";
|
|
@@ -613,6 +614,8 @@ function printJson(value) {
|
|
|
613
614
|
omitted: true,
|
|
614
615
|
bytes: Buffer.byteLength(json) + 1,
|
|
615
616
|
limitBytes,
|
|
617
|
+
// Callers that always want one path can ask for the whole reply.
|
|
618
|
+
inline: "--stdout prints the whole result instead",
|
|
616
619
|
};
|
|
617
620
|
json = encode(brief);
|
|
618
621
|
if (brief.matrixPath && Buffer.byteLength(json) + 1 > limitBytes) {
|
|
@@ -1255,11 +1258,11 @@ async function writeReply(result, args) {
|
|
|
1255
1258
|
const files =
|
|
1256
1259
|
(result.changedFiles?.length ?? 0) + (result.deletedFiles?.length ?? 0);
|
|
1257
1260
|
const warnings = result.warningSummary?.total ?? result.warnings?.length ?? 0;
|
|
1258
|
-
const verb = result.
|
|
1259
|
-
? "
|
|
1260
|
-
: result.
|
|
1261
|
-
? "
|
|
1262
|
-
: "Applied";
|
|
1261
|
+
const verb = result.alreadyApplied
|
|
1262
|
+
? "Already applied"
|
|
1263
|
+
: result.changed === false
|
|
1264
|
+
? result.dryRun ? "No changes (preview)" : "No changes"
|
|
1265
|
+
: result.dryRun ? "Previewed" : "Applied";
|
|
1263
1266
|
// Empty advice says nothing: leave it out of the short reply.
|
|
1264
1267
|
if (!rest.reuseReminders?.length) delete rest.reuseReminders;
|
|
1265
1268
|
if (!rest.reuseSummary?.total) delete rest.reuseSummary;
|
|
@@ -2419,13 +2422,45 @@ function repairChoiceCommand(locator, productPackageId, conflictIndex, choice) {
|
|
|
2419
2422
|
return { argv: base };
|
|
2420
2423
|
}
|
|
2421
2424
|
|
|
2425
|
+
// Stored settings the App cannot make, rewritten the App's way without a
|
|
2426
|
+
// visible change: texts stored as hug (text-sizing).
|
|
2427
|
+
const STORAGE_REPAIRS = ["text-sizing"];
|
|
2428
|
+
|
|
2422
2429
|
async function repairCommand(packagePath, args) {
|
|
2423
2430
|
const resolution = await resolveCliWorkspace(packagePath);
|
|
2431
|
+
const action = option(args, "--action");
|
|
2432
|
+
if (STORAGE_REPAIRS.includes(action) && resolution.status !== "ready")
|
|
2433
|
+
throw new SmallPenError("repair_conflicts_first", `Resolve the package's conflicts before ${action}; run project repair without --action to list them`);
|
|
2424
2434
|
if (resolution.status === "ready") {
|
|
2435
|
+
const product = resolution.workspace.product;
|
|
2436
|
+
const textSizing = textSizingRepairOperations(product);
|
|
2437
|
+
if (action === "text-sizing") {
|
|
2438
|
+
if (!textSizing.count) return { action, changed: false, count: 0, revision: product.revision, status: "ready" };
|
|
2439
|
+
const result = await trackedWrite(() =>
|
|
2440
|
+
applyOperationBatch(product.locator, {
|
|
2441
|
+
baseRevision: product.revision,
|
|
2442
|
+
batchId: option(args, "--batch-id") ?? `repair_${randomUUID()}`,
|
|
2443
|
+
operations: textSizing.operations,
|
|
2444
|
+
}),
|
|
2445
|
+
);
|
|
2446
|
+
return {
|
|
2447
|
+
action, count: textSizing.count, places: textSizing.places.length,
|
|
2448
|
+
done: `${textSizing.count} text sizes in ${textSizing.places.length} pages and variants stored the App's way (hug is a text's grow type); the design looks the same`,
|
|
2449
|
+
batchId: result.batchId, revision: result.revision, changed: result.changed,
|
|
2450
|
+
inverseBatchPath: result.inverseBatchPath, status: "ready",
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
if (action !== undefined)
|
|
2454
|
+
throw new SmallPenError("unknown_repair_action", `The package has no conflicts; the repairs it can take are ${STORAGE_REPAIRS.join(", ")}`, { actions: STORAGE_REPAIRS });
|
|
2425
2455
|
return {
|
|
2426
|
-
packageId:
|
|
2427
|
-
revision:
|
|
2456
|
+
packageId: product.manifest.packageId,
|
|
2457
|
+
revision: product.revision,
|
|
2428
2458
|
status: "ready",
|
|
2459
|
+
...(textSizing.count ? { repairs: [{
|
|
2460
|
+
action: "text-sizing", count: textSizing.count, places: textSizing.places.length,
|
|
2461
|
+
message: `${textSizing.count} text sizes are stored as hug, which the App does not offer for a text; this stores them the App's way without a visible change`,
|
|
2462
|
+
command: ["project", "repair", resolve(packagePath), "--action", "text-sizing", "--json"],
|
|
2463
|
+
}] } : {}),
|
|
2429
2464
|
};
|
|
2430
2465
|
}
|
|
2431
2466
|
if (!option(args, "--action")) {
|
|
@@ -2451,7 +2486,6 @@ async function repairCommand(packagePath, args) {
|
|
|
2451
2486
|
};
|
|
2452
2487
|
}
|
|
2453
2488
|
const { index, selected } = selectedRepairConflict(resolution, args);
|
|
2454
|
-
const action = option(args, "--action");
|
|
2455
2489
|
const operations = await repairOperations(resolution, selected, action, args);
|
|
2456
2490
|
const result = await trackedWrite(() =>
|
|
2457
2491
|
applyOperationBatch(resolution.product.locator, {
|
|
@@ -3700,12 +3734,12 @@ async function namedContextArgs(packagePath, args) {
|
|
|
3700
3734
|
}
|
|
3701
3735
|
|
|
3702
3736
|
// page set / component set: the target by name, the fields from --set
|
|
3703
|
-
// FIELD=VALUE (a JSON value, else text)
|
|
3737
|
+
// FIELD=VALUE (a JSON value, else text) alongside --intent {"set": {...}}.
|
|
3704
3738
|
async function setIntent(command, args) {
|
|
3705
3739
|
const file = option(args, "--intent");
|
|
3706
3740
|
const base = file ? parseJson(await readInputFile(file, "--intent", "utf8"), `invalid_${command.replace("-", "_")}_json`) : {};
|
|
3707
3741
|
if (!isRecord(base)) throw new SmallPenError("invalid_set", "--intent holds {\"set\": {...}}");
|
|
3708
|
-
const set =
|
|
3742
|
+
const set = Object.assign(Object.create(null), isRecord(base.set) ? base.set : {});
|
|
3709
3743
|
for (const assignment of options(args, "--set")) {
|
|
3710
3744
|
const at = assignment.indexOf("=");
|
|
3711
3745
|
if (at <= 0) throw new SmallPenError("invalid_set", `--set takes FIELD=VALUE, not ${assignment}`);
|
|
@@ -3718,7 +3752,12 @@ async function setIntent(command, args) {
|
|
|
3718
3752
|
} catch {
|
|
3719
3753
|
value = raw;
|
|
3720
3754
|
}
|
|
3721
|
-
|
|
3755
|
+
const field = assignment.slice(0, at).trim();
|
|
3756
|
+
if (Object.hasOwn(set, field) && canonicalJSON(set[field]) !== canonicalJSON(value))
|
|
3757
|
+
throw new SmallPenError("conflicting_set_field", `Conflicting values for ${field}; give this field one value`, {
|
|
3758
|
+
field, previous: set[field], received: value,
|
|
3759
|
+
});
|
|
3760
|
+
set[field] = value;
|
|
3722
3761
|
}
|
|
3723
3762
|
const target = command === "page-set"
|
|
3724
3763
|
? { page: option(args, "--page") ?? base.page, platform: option(args, "--platform") ?? base.platform }
|
|
@@ -4386,6 +4425,8 @@ async function main(args) {
|
|
|
4386
4425
|
const wraps = [];
|
|
4387
4426
|
const asked = [];
|
|
4388
4427
|
let calls = 0;
|
|
4428
|
+
// The App's flex sizing rules, so the CLI stores what the App would.
|
|
4429
|
+
const fixSizing = await layoutSizingRules();
|
|
4389
4430
|
const plan = () => {
|
|
4390
4431
|
calls = 0;
|
|
4391
4432
|
return ({ "component-define": componentDefineOperation, "page-draw": pageDrawOperation, "page-set": pageSetOperation, "component-set": componentSetOperation })[command](
|
|
@@ -4393,6 +4434,7 @@ async function main(args) {
|
|
|
4393
4434
|
intent,
|
|
4394
4435
|
{
|
|
4395
4436
|
...lookups,
|
|
4437
|
+
fixSizing,
|
|
4396
4438
|
measure: (tree) => {
|
|
4397
4439
|
const index = calls++;
|
|
4398
4440
|
trees[index] ??= tree;
|
|
@@ -5209,17 +5251,9 @@ async function main(args) {
|
|
|
5209
5251
|
if (signatureTtf) {
|
|
5210
5252
|
inspectFont(bytes, "font/ttf");
|
|
5211
5253
|
files.ttf = { bytes, mimeType: "font/ttf" };
|
|
5212
|
-
files.woff = {
|
|
5213
|
-
bytes: sfntToWoff(bytes, "font/ttf"),
|
|
5214
|
-
mimeType: "font/woff",
|
|
5215
|
-
};
|
|
5216
5254
|
} else if (signatureOtf) {
|
|
5217
5255
|
inspectFont(bytes, "font/otf");
|
|
5218
5256
|
files.otf = { bytes, mimeType: "font/otf" };
|
|
5219
|
-
files.woff = {
|
|
5220
|
-
bytes: sfntToWoff(bytes, "font/otf"),
|
|
5221
|
-
mimeType: "font/woff",
|
|
5222
|
-
};
|
|
5223
5257
|
} else if (signatureWoff2) {
|
|
5224
5258
|
inspectFont(bytes, "font/woff2");
|
|
5225
5259
|
files.woff2 = { bytes, mimeType: "font/woff2" };
|
|
@@ -1,23 +1,31 @@
|
|
|
1
1
|
import { Worker } from "node:worker_threads";
|
|
2
2
|
|
|
3
|
+
// A renderer that panicked or ran out of memory stays broken in its worker.
|
|
4
|
+
const RENDERER_FAILURE = /panic|wasm|render_failed|validation_worker_failed/;
|
|
5
|
+
|
|
3
6
|
// A persistent worker reuses the shared App runtime. Terminating the worker
|
|
4
7
|
// also stops synchronous WASM; a Promise timeout in this process cannot.
|
|
8
|
+
// WASM memory only grows, so a worker is replaced after jobsPerWorker
|
|
9
|
+
// targets, and a target the renderer failed on is tried once more in a
|
|
10
|
+
// fresh worker.
|
|
5
11
|
export async function runValidationJobs(workspace, jobs, {
|
|
6
|
-
timeoutMs = 30_000, signal, progress = () => {},
|
|
12
|
+
timeoutMs = 30_000, signal, progress = () => {}, jobsPerWorker = 12,
|
|
7
13
|
workerUrl = new URL("./validation-worker.mjs", import.meta.url),
|
|
8
14
|
} = {}) {
|
|
9
15
|
const results = [];
|
|
10
|
-
let worker;
|
|
16
|
+
let worker, served = 0;
|
|
11
17
|
const emit = (event, extra = {}) => progress({
|
|
12
18
|
type: "validation-progress", event, total: jobs.length, completed: results.length, ...extra,
|
|
13
19
|
});
|
|
14
20
|
emit("start");
|
|
15
21
|
try {
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
const runOne = job => {
|
|
23
|
+
if (!worker) {
|
|
24
|
+
worker = new Worker(workerUrl, { workerData: workspace });
|
|
25
|
+
served = 0;
|
|
26
|
+
}
|
|
27
|
+
served += 1;
|
|
28
|
+
return new Promise(resolve => {
|
|
21
29
|
let finished = false;
|
|
22
30
|
const finish = value => {
|
|
23
31
|
if (finished) return;
|
|
@@ -44,10 +52,22 @@ export async function runValidationJobs(workspace, jobs, {
|
|
|
44
52
|
if (signal?.aborted) onAbort();
|
|
45
53
|
else worker.postMessage(job);
|
|
46
54
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
};
|
|
56
|
+
const restart = async () => {
|
|
57
|
+
await worker?.terminate();
|
|
58
|
+
worker = undefined;
|
|
59
|
+
};
|
|
60
|
+
for (const job of jobs) {
|
|
61
|
+
if (signal?.aborted) break;
|
|
62
|
+
emit("target", { target: job.label });
|
|
63
|
+
if (served >= jobsPerWorker) await restart();
|
|
64
|
+
let result = await runOne(job);
|
|
65
|
+
if (!result.interrupted && RENDERER_FAILURE.test(result.error?.code ?? "")) {
|
|
66
|
+
await restart();
|
|
67
|
+
emit("retry", { target: job.label, reason: result.error.code });
|
|
68
|
+
result = await runOne(job);
|
|
50
69
|
}
|
|
70
|
+
if (result.restart || RENDERER_FAILURE.test(result.error?.code ?? "")) await restart();
|
|
51
71
|
if (result.interrupted) break;
|
|
52
72
|
results.push({ job, ...result });
|
|
53
73
|
emit("completed", { target: job.label, status: result.error ? result.error.code : "checked" });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smallpen/cli",
|
|
3
|
-
"version": "0.1.0-alpha.
|
|
3
|
+
"version": "0.1.0-alpha.19",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -15,8 +15,8 @@
|
|
|
15
15
|
"bin"
|
|
16
16
|
],
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@smallpen/core": "0.1.0-alpha.
|
|
19
|
-
"@smallpen/local-package": "0.1.0-alpha.
|
|
18
|
+
"@smallpen/core": "0.1.0-alpha.19",
|
|
19
|
+
"@smallpen/local-package": "0.1.0-alpha.19"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|