@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.
@@ -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")'),
@@ -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 OTF are validated by SFNT
329
- signature and converted to WOFF (the renderer loads ttf/otf/woff). WOFF imports
330
- as-is after table validation. A valid WOFF2 reaches an explicit
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 package.revision from smallpen inspect PACKAGE --json.
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 = "<package.revision from smallpen inspect PACKAGE --json>";
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 inspect PACKAGE) into baseRevision before applying the batch. Existing IDs replace whole definitions; use update for partial edits.",
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
- "package.revision from smallpen inspect PACKAGE --json; a stale value is rejected",
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
- token:
732
- "smallpen token PACKAGE --intent {operations:[...]}: like apply, with baseRevision and batchId filled in",
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"; the only text in a row fills by default, several texts in a row 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.dryRun
1259
- ? "Previewed"
1260
- : result.alreadyApplied
1261
- ? "Already applied"
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: resolution.workspace.product.manifest.packageId,
2427
- revision: resolution.workspace.product.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) on top of an --intent {"set": {...}}.
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 = { ...(isRecord(base.set) ? base.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
- set[assignment.slice(0, at).trim()] = value;
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
- for (const job of jobs) {
17
- if (signal?.aborted) break;
18
- emit("target", { target: job.label });
19
- worker ??= new Worker(workerUrl, { workerData: workspace });
20
- const result = await new Promise(resolve => {
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
- if (result.restart) {
48
- await worker.terminate();
49
- worker = undefined;
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.16",
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.16",
19
- "@smallpen/local-package": "0.1.0-alpha.16"
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",