partforge 0.112.0 → 0.114.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +141 -2
- package/docs/ERROR-PATTERNS.md +6 -0
- package/package.json +8 -1
- package/src/framework/app.css +50 -2
- package/src/framework/geometry/polygon.js +67 -5
- package/src/framework/lint/rules-schema.js +56 -0
- package/src/framework/lint/source-scan.js +116 -2
- package/src/framework/mount.js +21 -2
- package/src/framework/panel/author.js +5 -0
- package/src/framework/panel/json-value.js +55 -0
- package/src/framework/panel/render.js +119 -36
- package/src/framework/panel/scoped-params.js +52 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/custom.js +253 -0
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/panel-values.js +6 -0
- package/types/geometry.d.ts +2 -0
- package/types/index.d.ts +66 -0
- package/types/panel-values.d.ts +18 -0
- package/types/part.d.ts +10 -1
package/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -436,6 +436,7 @@ offsetPolygon(outline, -wall, { corners: "sharp" }); // inse
|
|
|
436
436
|
const tab = pathProfile([0, -w / 2])
|
|
437
437
|
.lineTo([len, -w / 2])
|
|
438
438
|
.arcTo([len, w / 2], [len + w / 2, 0]) // tip, via the apex
|
|
439
|
+
// same arc: .arcTo([len, w / 2], { r: w / 2 }) — radius form, no via to compute
|
|
439
440
|
.lineTo([0, w / 2])
|
|
440
441
|
.close();
|
|
441
442
|
k.extrude({ profile: tab, h: 3 });
|
|
@@ -445,6 +446,7 @@ const lip = pathProfile([0, 0])
|
|
|
445
446
|
.lineTo([20, 0]).lineTo([20, 8])
|
|
446
447
|
.cubicTo([0, 8], [14, 16], [6, 16]) // curved top edge
|
|
447
448
|
.close();
|
|
449
|
+
k.extrude({ profile: lip, h: 3 });
|
|
448
450
|
|
|
449
451
|
// Rounded enclosure: soft vertical edges, a softer lid, a flat base.
|
|
450
452
|
const shell = k.roundedBox({ size: [60, 40, 22], round: { side: 4, top: 2, bottom: 0 } });
|
|
@@ -470,7 +472,7 @@ dumbbell past its waist) **throws** a greppable error rather than returning dege
|
|
|
470
472
|
geometry. Being pure, it works in `derive()` as well as `build()` — the natural home for
|
|
471
473
|
clearance math.
|
|
472
474
|
`pathProfile(start)` is a fluent builder for a curve-native path contour (`lineTo` / `arcTo` / `cubicTo` / `close`); cubic segments become exact B-rep spline edges on the OCCT/STEP backend and facet at the mesh LOD on Manifold — the same exact-vs-faceted split as `roundedProfile` arcs.
|
|
473
|
-
`arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
|
|
475
|
+
`arcTo(to, via)` is a **three-point arc**: `via` is any point on the arc between the current point and `to` (its midpoint is the natural choice), and the sweep is whichever direction passes through it — so an arc's direction is a property of a point you can see, never of a sign. `arcTo(to, { r, sweep?, large? })` is the **radius form** for when you know the radius, not a point on the arc: it computes `via` from the current point, `to`, and `r`, emitting the exact same `{to, via}` segment the three-point form does. `sweep` names the direction the arc itself is traversed (default `"ccw"`), so on a counter-clockwise outline `"ccw"` bulges OUTWARD (a convex bump), and inward on a clockwise hole; `"cw"` is the reverse. `large` (default `false`) picks the major arc over the minor one when both are possible. A radius shorter than half the distance between the current point and `to` throws rather than being silently scaled up (the way SVG's arc command does) — the smallest circle joining the two points is a semicircle at `r = d/2`. Build the symmetric half of a profile once and `mirrorProfile` it (see "Editing profiles") rather than writing the mirrored arcs by hand. `loft` accepts these contours as rings (every ring with the same segment signature lofts curve-to-curve).
|
|
474
476
|
**`pathProfile` or an authored vector file?** Reach for `pathProfile` (and the polygon helpers above) when the geometry is **computed from parameters** — a profile whose dimensions come from `p`/`d`, which a JSON file cannot see. Reach for an authored `partforge-vector` document (`k.vector2d`, see "Vector geometry" below) when the geometry is **drawn** — a logo, a faceplate outline, a decorative cutout, where each number means one thing and gets edited on its own. The two are freely composable: both produce ordinary 2-D geometry that the same booleans and editing ops accept.
|
|
475
477
|
**Import geometry helpers from `partforge/geometry`, never from `partforge`** — the main
|
|
476
478
|
entry pulls in the DOM viewer/controls, and your build functions run in a Web Worker
|
|
@@ -743,6 +745,7 @@ Every control accepts `key`, `type`, `label`, `description`, `hidden`, `when` an
|
|
|
743
745
|
| `"font"` | a typeface picker with a catalog, else a drop target | `allow`, `preview`, `sourceField` |
|
|
744
746
|
| `"image"` | an image picker with a catalog, else a drop target showing the artwork | `allow`, `sourceField` |
|
|
745
747
|
| `"vector"` | a drop target showing the artwork — no catalog exists | `sourceField` |
|
|
748
|
+
| `"custom"` | a widget the part draws itself — see "Custom controls" below | `widget`, `keys` |
|
|
746
749
|
|
|
747
750
|
Numeric controls always show the number box: drag the slider *or* type an exact
|
|
748
751
|
value. Typed values may be finer than `step` and may sit **outside `[min, max]`**:
|
|
@@ -952,6 +955,132 @@ pickers in one section both need to show divergence, give each its own section.
|
|
|
952
955
|
case per preset name (so every preset gets measured), and a repeated name throws
|
|
953
956
|
there. `duplicate-preset-name` catches it at lint time instead.
|
|
954
957
|
|
|
958
|
+
### Custom controls
|
|
959
|
+
|
|
960
|
+
A `type: "custom"` control renders a widget the part draws itself — a clickable hex
|
|
961
|
+
grid, an organizer whose walls toggle on click, a picture of the part's own outline
|
|
962
|
+
with hot regions. Reach for it when the user configures **many similar things
|
|
963
|
+
individually**, or makes a **spatial choice** no slider or select can express.
|
|
964
|
+
Never for a value an existing control already covers: a slider is still a slider.
|
|
965
|
+
|
|
966
|
+
```js
|
|
967
|
+
// part.js
|
|
968
|
+
import { tilePicker } from "./tile-picker.js";
|
|
969
|
+
|
|
970
|
+
export default {
|
|
971
|
+
defaults: {
|
|
972
|
+
tileSize: 20,
|
|
973
|
+
tiles: [{ q: 0, r: 0, height: 10 }, { q: 1, r: 0, height: 14 }],
|
|
974
|
+
},
|
|
975
|
+
parameters: [{
|
|
976
|
+
title: "Tiles",
|
|
977
|
+
controls: [
|
|
978
|
+
{ type: "slider", key: "tileSize", min: 10, max: 40 },
|
|
979
|
+
{ type: "custom", key: "tiles", label: "Tile layout", widget: tilePicker,
|
|
980
|
+
description: "Click a tile to select it. Drag to move it." },
|
|
981
|
+
],
|
|
982
|
+
}],
|
|
983
|
+
parts: { tiles: { build: (k, p) => /* p.tiles is the array */ } },
|
|
984
|
+
};
|
|
985
|
+
```
|
|
986
|
+
|
|
987
|
+
- `key` names the param the widget owns. It may hold a **JSON value**: numbers,
|
|
988
|
+
strings, booleans, arrays and plain objects of those, at most 16 KB serialized and
|
|
989
|
+
8 levels deep, never `null` (`custom-default-not-json`). `build()` reads it like any
|
|
990
|
+
other param. This is the one place the "a control writes one scalar" rule is relaxed.
|
|
991
|
+
- `keys` (optional) lists further scalar params the widget may also write.
|
|
992
|
+
- `widget` is a function `(host) => …`, normally imported from a sibling file so
|
|
993
|
+
`part.js` stays readable (`custom-control-widget-not-function`).
|
|
994
|
+
- `label`, `description`, `hidden`, `when` and `whenFalse` behave as on any control.
|
|
995
|
+
|
|
996
|
+
**The widget function.** It runs once per mount and draws into `host.el` with plain
|
|
997
|
+
DOM and SVG. It may return `{ update, dispose }`.
|
|
998
|
+
|
|
999
|
+
```js
|
|
1000
|
+
// tile-picker.js
|
|
1001
|
+
export function tilePicker(host) {
|
|
1002
|
+
const svg = host.h("svg", { viewBox: "0 0 200 160", width: "100%" });
|
|
1003
|
+
const detail = host.h("div");
|
|
1004
|
+
host.el.append(svg, detail);
|
|
1005
|
+
let stopDetail = null;
|
|
1006
|
+
|
|
1007
|
+
function draw() {
|
|
1008
|
+
const tiles = host.get(); // a fresh clone of the owned value
|
|
1009
|
+
const sel = host.state.selected ?? null; // survives the remount an edit performs
|
|
1010
|
+
svg.replaceChildren(...tiles.map((t, i) => host.h("polygon", {
|
|
1011
|
+
points: hexPoints(t.q, t.r), class: i === sel ? "pf-hit selected" : "pf-hit",
|
|
1012
|
+
onpointerdown: () => { host.setState({ selected: i }); draw(); },
|
|
1013
|
+
})));
|
|
1014
|
+
stopDetail?.();
|
|
1015
|
+
stopDetail = sel === null ? null : host.controls(detail, [
|
|
1016
|
+
{ type: "slider", key: "height", label: "Height", min: 4, max: 30 },
|
|
1017
|
+
], { path: `${sel}` });
|
|
1018
|
+
}
|
|
1019
|
+
draw();
|
|
1020
|
+
return { update: draw, dispose: () => stopDetail?.() };
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
**The host.**
|
|
1025
|
+
|
|
1026
|
+
| Member | Meaning |
|
|
1027
|
+
|---|---|
|
|
1028
|
+
| `host.el`, `host.doc` | The slot to draw into, and its document. |
|
|
1029
|
+
| `host.get(key?)` | A clone of the current value (default: the owned key). Any param is readable. |
|
|
1030
|
+
| `host.set(value, {key?, commit?})` | Replace a value with a **new** one — never mutate what `get` returned. Schedules the rebuild and commits, unless `commit: false`; then call `host.commit()` when the gesture ends (a drag). Throws for a key you do not own or a value outside the contract. On a **retired** widget (one whose code already threw) `set`, `commit` and `setState` are silent no-ops. |
|
|
1031
|
+
| `host.commit(keys?)` | Ends a deferred gesture. A no-op when nothing changed. |
|
|
1032
|
+
| `host.derived` | The latest `derive()` output, the same object readouts show. |
|
|
1033
|
+
| `host.state`, `host.setState(patch)` | Transient JSON (a selection, an open panel). Not a param, never persisted, but it **survives a remount**, which every edit performs. |
|
|
1034
|
+
| `host.controls(container, controls, {path})` | Mount ordinary built-in controls bound *inside* the owned value at a dotted `path` (`"3"`, `"walls.north"`). Their edits commit the owning key. Returns a disposer. One level: no custom control inside, and no `font`, `image` or `vector` sub-control at a path (their asset lookups are keyed on the part's real param names). |
|
|
1035
|
+
| `host.h(tag, attrs, ...children)` | Element builder. SVG tags get the SVG namespace; `on<event>` attrs become listeners; `class` and `style` pass through. |
|
|
1036
|
+
| `host.svg(text)` | Parse an SVG string to an element you can append and wire up. |
|
|
1037
|
+
| `host.svgFromVector(doc)` | A partforge-vector document → inline `<svg>` (the same renderer the `vector` control uses). |
|
|
1038
|
+
| `host.file(pathOrToken)` | Text of one of the part's own files, by path or a `pfc-tree://` token, or null. |
|
|
1039
|
+
| `host.disabled` | Whether a `when`/`whenFalse: "disable"` currently disables this control. |
|
|
1040
|
+
|
|
1041
|
+
`update({reason, disabled})` is called when your key changes from outside the widget
|
|
1042
|
+
(`reason: "sync"` — a preset, undo, `setParams`), when `derived` changes
|
|
1043
|
+
(`"derived"`), and once after `host.state` was restored on mount (`"restore"`). It is
|
|
1044
|
+
**not** called for your own `set`. `dispose()` runs on teardown.
|
|
1045
|
+
|
|
1046
|
+
**Rules that keep it working.**
|
|
1047
|
+
|
|
1048
|
+
1. Size SVG with `viewBox` plus `width: 100%`. The rail is 288px wide by default and
|
|
1049
|
+
narrower on a phone, where it is the bottom sheet.
|
|
1050
|
+
2. Use pointer events, not mouse events, and put `class="pf-drag"` (or
|
|
1051
|
+
`touch-action: none`) on anything dragged — otherwise a finger scrolls the sheet.
|
|
1052
|
+
3. Keep selection and similar state in `host.state`, not in a closure: every edit
|
|
1053
|
+
remounts the part and your function runs again.
|
|
1054
|
+
4. Hand `set` a new value. `get` returns a clone precisely so the stored value is never
|
|
1055
|
+
edited in place.
|
|
1056
|
+
5. A throw in creation, `update`, or a listener installed through `host.h` replaces
|
|
1057
|
+
the widget with an error card and reports it (a hosting agent sees it in the apply
|
|
1058
|
+
result as `panelErrors`); other controls keep working. A throw in `dispose` is
|
|
1059
|
+
reported the same way but leaves no card — there is nothing left to show it on. A
|
|
1060
|
+
listener you attach yourself with `addEventListener`, rather than through `host.h`,
|
|
1061
|
+
is **not** guarded — wire listeners through `host.h`, or wrap your own in try/catch.
|
|
1062
|
+
|
|
1063
|
+
**Looking native.** The slot inherits the rail's font, colours and light/dark theme.
|
|
1064
|
+
Bare `<button>`, `<input>` and `<select>` elements pick up the built-in looks
|
|
1065
|
+
automatically; the built-in classes are available by name for the exact thing:
|
|
1066
|
+
`row`, `seg` (a segmented row of buttons), `action`, `ghost`, `num`, `text-input`,
|
|
1067
|
+
`select-input`; and for SVG, `pf-hit` (clickable, with a `selected` state) and
|
|
1068
|
+
`pf-drag`. Sub-controls mounted through `host.controls` *are* the built-in widgets.
|
|
1069
|
+
|
|
1070
|
+
**Reading the part's own files.** Artwork can live beside the code (the tree is text,
|
|
1071
|
+
so an SVG, a `partforge-vector` JSON document or a JSON data file — not a PNG).
|
|
1072
|
+
Two routes: store the SVG as a string in a JS module (`assets/emblem.svg.js` exporting
|
|
1073
|
+
a template literal) and import it like any sibling file — no framework support
|
|
1074
|
+
needed; or read it with `host.file("assets/emblem.svg")` and inline it with
|
|
1075
|
+
`host.svg(text)`. Give regions ids and wire `pointerdown` on `#wall-3` to toggle
|
|
1076
|
+
`walls[3]` in the owned value.
|
|
1077
|
+
|
|
1078
|
+
**What a widget cannot do.** Never `fetch` or otherwise reach the network — a widget
|
|
1079
|
+
is a pure function of the part and its params, and a hosted sandbox may refuse the
|
|
1080
|
+
request or have no credentials to make it with. Also: no imports beyond the part's
|
|
1081
|
+
own files, nothing outside `host.el`, and no reading of `params` except through
|
|
1082
|
+
`host`.
|
|
1083
|
+
|
|
955
1084
|
### Legacy section shapes (still supported)
|
|
956
1085
|
|
|
957
1086
|
Everything above is what a **new part should write**. The original array-based shapes
|
|
@@ -1141,6 +1270,10 @@ defaulting everything to a slider:
|
|
|
1141
1270
|
reason for a two-position slider to exist.
|
|
1142
1271
|
- **A computed value the user should see but not set** → `"readout"`. It costs no
|
|
1143
1272
|
parameter and answers the "so what did that do?" question in place.
|
|
1273
|
+
- **Many similar things configured individually, or a spatial choice** (which tiles
|
|
1274
|
+
exist and how tall each is; which walls of an organizer are present) →
|
|
1275
|
+
`"custom"`: a widget the part draws, holding one JSON value. See "Custom controls"
|
|
1276
|
+
above; use it only when no built-in control expresses the choice.
|
|
1144
1277
|
|
|
1145
1278
|
Then gate what doesn't always apply. A control that is meaningless in the current mode
|
|
1146
1279
|
should carry a **`when`** rather than sit there inert — hide it by default, or use
|
|
@@ -2789,7 +2922,9 @@ previously didn't; that's the fix working as intended, not a regression.
|
|
|
2789
2922
|
`preset-key-not-in-defaults`, `mixed-section-shape`,
|
|
2790
2923
|
`duplicate-preset-name`, `duplicate-node-id`, `select-options-missing`,
|
|
2791
2924
|
`select-default-not-in-options`, `log-scale-needs-positive-min`,
|
|
2792
|
-
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type
|
|
2925
|
+
`when-key-not-in-defaults`, `when-unknown-operator`, `unknown-control-type`,
|
|
2926
|
+
`custom-control-widget-not-function`, `custom-default-not-json`,
|
|
2927
|
+
`custom-keys-not-in-defaults` (errors);
|
|
2793
2928
|
`slider-range-excludes-default`, `unknown-control-field`, `duplicate-control-key`,
|
|
2794
2929
|
`default-not-exposed`, `readout-unknown-derived-key`, `slider-refinement-invalid`,
|
|
2795
2930
|
`group-depth`, `section-too-many-controls` (warnings).
|
|
@@ -2800,6 +2935,10 @@ can't coexist, since mixing them would make the render order arbitrary. Move
|
|
|
2800
2935
|
the legacy entries into `controls` (a toggle becomes a checkbox control,
|
|
2801
2936
|
`advanced` becomes a nested group, `presets` becomes `{ type: "preset" }`
|
|
2802
2937
|
nodes), or drop `controls` and stay legacy.
|
|
2938
|
+
`custom-default-not-json` is `control-default-not-primitive`'s counterpart for a
|
|
2939
|
+
`type: "custom"` control, whose key may hold a JSON value (see "Custom controls"):
|
|
2940
|
+
it names the first member that is not one — `null`, a function, a class instance,
|
|
2941
|
+
a forbidden key, or a value past the 16 KB / depth-8 caps.
|
|
2803
2942
|
`duplicate-preset-name` fires when the same preset name is declared twice
|
|
2804
2943
|
(legacy `presets` and/or `{ type: "preset" }` nodes both count) — preset names
|
|
2805
2944
|
are global to the part, and `verify()` expands one case per name and throws on
|
package/docs/ERROR-PATTERNS.md
CHANGED
|
@@ -316,6 +316,12 @@ Variant literals under this entry: `offsetPolygon: delta must be a finite number
|
|
|
316
316
|
- **Cause:** A cubic segment is missing `c1` or `c2`, or a control point is not a finite `[x,y]` (e.g. `NaN`, wrong length).
|
|
317
317
|
- **Fix:** Provide both control points as finite `[x,y]`. A cubic Bézier needs two controls between the previous point and `to`.
|
|
318
318
|
|
|
319
|
+
## arcto-radius-too-short
|
|
320
|
+
|
|
321
|
+
- **Symptom:** `pathProfile: arcTo r=<r> is shorter than half the chord (<half-chord>) from (<x0>, <y0>) to (<x1>, <y1>) — the smallest arc that can join these points has r=<half-chord> (a semicircle)`
|
|
322
|
+
- **Cause:** `pathProfile().arcTo(to, { r, sweep?, large? })`'s `r` is smaller than half the distance between the current point and `to` — no circle of that radius passes through both points.
|
|
323
|
+
- **Fix:** Raise `r` to at least half the chord (the message states the exact minimum), or move the endpoint closer. Unlike SVG's arc command, partforge refuses rather than silently scaling `r` up to fit — the model should learn the number it wrote was wrong rather than have it quietly corrected.
|
|
324
|
+
|
|
319
325
|
## shape2d-simple-not-single-region
|
|
320
326
|
|
|
321
327
|
- **Symptom:** `Shape2D.simple: result has N regions, not 1 (use toRegions())`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "partforge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.114.0",
|
|
4
4
|
"description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -58,6 +58,10 @@
|
|
|
58
58
|
"types": "./types/ingest.d.ts",
|
|
59
59
|
"default": "./src/ingest.js"
|
|
60
60
|
},
|
|
61
|
+
"./panel-values": {
|
|
62
|
+
"types": "./types/panel-values.d.ts",
|
|
63
|
+
"default": "./src/panel-values.js"
|
|
64
|
+
},
|
|
61
65
|
"./tokens.css": "./src/framework/tokens.css",
|
|
62
66
|
"./chrome.css": "./src/framework/chrome.css"
|
|
63
67
|
},
|
|
@@ -83,6 +87,9 @@
|
|
|
83
87
|
],
|
|
84
88
|
"ingest": [
|
|
85
89
|
"./types/ingest.d.ts"
|
|
90
|
+
],
|
|
91
|
+
"panel-values": [
|
|
92
|
+
"./types/panel-values.d.ts"
|
|
86
93
|
]
|
|
87
94
|
}
|
|
88
95
|
},
|
package/src/framework/app.css
CHANGED
|
@@ -64,8 +64,10 @@ canvas { display: block; }
|
|
|
64
64
|
Condition-hidden sections (.hidden, a `when` that evaluated false) are excluded
|
|
65
65
|
for the same reason. Do not simplify this to `.section:first-child { border-top: 0 }` — that
|
|
66
66
|
matches DOM position rather than visibility, and leaves a stray divider
|
|
67
|
-
floating at the top whenever applyRelevance hides the first section.
|
|
68
|
-
|
|
67
|
+
floating at the top whenever applyRelevance hides the first section. A bare
|
|
68
|
+
sub-panel section (host.controls inside a custom control) never gets the divider
|
|
69
|
+
because the widget owns its framing. */
|
|
70
|
+
.section:not(.section-hidden):not(.hidden) ~ .section:not(.section-hidden):not(.hidden):not(.bare) {
|
|
69
71
|
border-top: 1px solid var(--pf-border);
|
|
70
72
|
}
|
|
71
73
|
/* Section disclosure header: the whole row is the click target (render.js
|
|
@@ -382,6 +384,52 @@ button.ghost { background: transparent; border: 1px solid var(--pf-border); colo
|
|
|
382
384
|
button.ghost:hover:not(:disabled) { background: var(--pf-surface-2); }
|
|
383
385
|
button.action:disabled { opacity: .5; cursor: default; }
|
|
384
386
|
|
|
387
|
+
/* Custom controls (type: "custom"): a part-authored widget in the rail. The
|
|
388
|
+
slot inherits the rail's cascade (font, colours, --pf-* tokens); these rules
|
|
389
|
+
give BARE elements the built-in controls' look so a widget written with
|
|
390
|
+
plain <button>/<input>/<select> still reads as native, and provide two
|
|
391
|
+
opt-in classes for SVG: .pf-hit (a clickable region) and .pf-drag (a
|
|
392
|
+
dragged one — touch-action: none is what lets a finger drag it on a phone
|
|
393
|
+
instead of scrolling the sheet). */
|
|
394
|
+
.pf-custom { margin: 9px 0; }
|
|
395
|
+
.pf-custom.hidden { display: none; }
|
|
396
|
+
.pf-custom-slot { display: flow-root; }
|
|
397
|
+
/* Every selector in this block is wrapped so the WHOLE thing resolves to
|
|
398
|
+
(0,0,0) — including the :not([type=...]) chain, which is otherwise an
|
|
399
|
+
attribute selector per :not() and would still out-specify a class rule
|
|
400
|
+
like .text-input (0,1,0) or .row .num (0,2,0). At true zero specificity,
|
|
401
|
+
any class rule wins, so a named built-in class (.seg button, .text-input,
|
|
402
|
+
select.select-input, …) always beats these bare-element defaults. */
|
|
403
|
+
:where(.pf-custom) :where(svg) { display: block; max-width: 100%; height: auto; }
|
|
404
|
+
:where(.pf-custom) :where(button) {
|
|
405
|
+
font: inherit; font-size: 12px; padding: 5px 9px; cursor: pointer;
|
|
406
|
+
background: transparent; border: 1px solid var(--pf-border); color: var(--pf-text-2);
|
|
407
|
+
border-radius: var(--pf-radius-control);
|
|
408
|
+
}
|
|
409
|
+
:where(.pf-custom) :where(button:hover:not(:disabled)) { background: var(--pf-surface-2); }
|
|
410
|
+
:where(.pf-custom) :where(button:disabled) { opacity: .5; cursor: default; }
|
|
411
|
+
:where(.pf-custom) :where(input:not([type="range"]):not([type="checkbox"]):not([type="radio"]), select) {
|
|
412
|
+
font: 12px/1.4 var(--pf-mono);
|
|
413
|
+
background: var(--pf-input-bg); color: var(--pf-text-strong);
|
|
414
|
+
border: 1px solid var(--pf-border); border-radius: var(--pf-radius-control); padding: 6px 8px;
|
|
415
|
+
}
|
|
416
|
+
:where(.pf-custom) :where(input:focus, select:focus, button:focus-visible) {
|
|
417
|
+
outline: none; border-color: var(--pf-accent);
|
|
418
|
+
box-shadow: 0 0 0 3px color-mix(in oklab, var(--pf-accent) 35%, transparent);
|
|
419
|
+
}
|
|
420
|
+
.pf-custom .pf-hit { cursor: pointer; }
|
|
421
|
+
.pf-custom .pf-hit:hover { fill: color-mix(in oklab, var(--pf-accent) 25%, transparent); }
|
|
422
|
+
.pf-custom .pf-hit.selected { fill: color-mix(in oklab, var(--pf-accent) 45%, transparent); stroke: var(--pf-accent); }
|
|
423
|
+
.pf-custom .pf-drag { touch-action: none; }
|
|
424
|
+
.pf-custom-error {
|
|
425
|
+
margin: 4px 0; padding: 6px 8px; border: 1px solid var(--pf-err); border-radius: var(--pf-radius-control);
|
|
426
|
+
color: var(--pf-err); font-family: var(--pf-mono); font-size: 11px; line-height: 1.4; word-break: break-word;
|
|
427
|
+
}
|
|
428
|
+
.pf-custom-error-title { font-weight: 600; margin-bottom: 2px; }
|
|
429
|
+
/* A sub-panel inside a custom control (host.controls): no header, no indent. */
|
|
430
|
+
.section.bare > .sec-body { padding: 0; }
|
|
431
|
+
.section.bare { padding: 0; border: 0; }
|
|
432
|
+
|
|
385
433
|
.dl { margin-top: 14px; }
|
|
386
434
|
.dl-head {
|
|
387
435
|
font-family: var(--pf-mono); font-size: 10px; font-weight: 600;
|
|
@@ -157,8 +157,9 @@ export function filletPolygon(points, r, { segs = 8 } = {}) {
|
|
|
157
157
|
}
|
|
158
158
|
|
|
159
159
|
// Fluent builder for a curve-native path contour { start, segments }. Segment kinds:
|
|
160
|
-
// lineTo → {to}, arcTo → {to,via} (three-point arc)
|
|
161
|
-
// close() returns the plain contour object
|
|
160
|
+
// lineTo → {to}, arcTo → {to,via} (three-point arc) or {to,via} computed from a radius
|
|
161
|
+
// spec, cubicTo → {to,c1,c2} (cubic Bézier). close() returns the plain contour object
|
|
162
|
+
// (feeds extrude/revolve/prism), not a Solid.
|
|
162
163
|
export function pathProfile(start) {
|
|
163
164
|
const fin2 = (p, what) => {
|
|
164
165
|
if (!Array.isArray(p) || p.length < 2 || !Number.isFinite(p[0]) || !Number.isFinite(p[1]))
|
|
@@ -166,12 +167,73 @@ export function pathProfile(start) {
|
|
|
166
167
|
return [p[0], p[1]];
|
|
167
168
|
};
|
|
168
169
|
const s = fin2(start, "start");
|
|
170
|
+
let cur = s;
|
|
169
171
|
const segments = [];
|
|
170
172
|
const api = {
|
|
171
|
-
lineTo(to) {
|
|
172
|
-
|
|
173
|
+
lineTo(to) {
|
|
174
|
+
const p = fin2(to, "lineTo point");
|
|
175
|
+
segments.push({ to: p });
|
|
176
|
+
cur = p;
|
|
177
|
+
return api;
|
|
178
|
+
},
|
|
179
|
+
// arcTo(to, via) is the three-point form: `via` is any point on the arc.
|
|
180
|
+
// arcTo(to, { r, sweep?, large? }) is the radius form: `via` is computed here
|
|
181
|
+
// from the current point, `to`, and the radius spec so the emitted segment is
|
|
182
|
+
// byte-for-byte what the three-point form emits (see docs/AUTHORING-PARTS.md).
|
|
183
|
+
arcTo(to, second) {
|
|
184
|
+
const p1 = fin2(to, "arcTo point");
|
|
185
|
+
if (Array.isArray(second)) {
|
|
186
|
+
const via = fin2(second, "arcTo via");
|
|
187
|
+
segments.push({ to: p1, via });
|
|
188
|
+
cur = p1;
|
|
189
|
+
return api;
|
|
190
|
+
}
|
|
191
|
+
if (second !== null && typeof second === "object") {
|
|
192
|
+
const ARC_SPEC_KEYS = ["r", "sweep", "large"];
|
|
193
|
+
const unknownKeys = Object.keys(second).filter((k) => !ARC_SPEC_KEYS.includes(k));
|
|
194
|
+
if (unknownKeys.length > 0)
|
|
195
|
+
throw new Error(
|
|
196
|
+
`pathProfile: arcTo arc spec has unknown ${unknownKeys.length > 1 ? "keys" : "key"} ${unknownKeys.map((k) => JSON.stringify(k)).join(", ")} — the keys are r, sweep, large`,
|
|
197
|
+
);
|
|
198
|
+
const { r, sweep = "ccw", large = false } = second;
|
|
199
|
+
// Cheap key/enum/boolean checks run BEFORE the numeric ones below, so a
|
|
200
|
+
// typo'd sweep/large is reported on its own rather than being masked by
|
|
201
|
+
// an unrelated radius complaint on the same call.
|
|
202
|
+
if (sweep !== "ccw" && sweep !== "cw")
|
|
203
|
+
throw new Error(`pathProfile: arcTo sweep must be "ccw" or "cw", got ${JSON.stringify(sweep)}`);
|
|
204
|
+
if (typeof large !== "boolean")
|
|
205
|
+
throw new Error("pathProfile: arcTo large must be a boolean");
|
|
206
|
+
const [x0, y0] = cur;
|
|
207
|
+
const [x1, y1] = p1;
|
|
208
|
+
const dx = x1 - x0, dy = y1 - y0;
|
|
209
|
+
const d = Math.hypot(dx, dy);
|
|
210
|
+
if (d < 1e-9)
|
|
211
|
+
throw new Error(`pathProfile: arcTo to (${x1}, ${y1}) coincides with the current point`);
|
|
212
|
+
if (!(r > 0) || !Number.isFinite(r))
|
|
213
|
+
throw new Error(`pathProfile: arcTo r must be > 0 and finite, got ${JSON.stringify(r)}`);
|
|
214
|
+
if (r < d / 2 - 1e-9)
|
|
215
|
+
throw new Error(
|
|
216
|
+
`pathProfile: arcTo r=${r} is shorter than half the chord (${(d / 2).toFixed(4)}) from (${x0}, ${y0}) to (${x1}, ${y1}) — the smallest arc that can join these points has r=${(d / 2).toFixed(4)} (a semicircle)`,
|
|
217
|
+
);
|
|
218
|
+
const rr = Math.max(r, d / 2); // absorb the 1e-9 tolerance so h is never NaN
|
|
219
|
+
const h = Math.sqrt(rr * rr - (d / 2) * (d / 2)); // centre's distance from the chord midpoint
|
|
220
|
+
const ux = dx / d, uy = dy / d; // unit chord direction
|
|
221
|
+
const nx = -uy, ny = ux; // unit LEFT normal of the direction of travel
|
|
222
|
+
const mx = (x0 + x1) / 2, my = (y0 + y1) / 2;
|
|
223
|
+
const side = sweep === "ccw" ? -1 : 1; // which side of the chord the arc bulges to
|
|
224
|
+
const sgn = large ? side : -side;
|
|
225
|
+
const cx = mx + nx * h * sgn, cy = my + ny * h * sgn;
|
|
226
|
+
const via = [cx + nx * rr * side, cy + ny * rr * side]; // the arc's midpoint
|
|
227
|
+
segments.push({ to: p1, via });
|
|
228
|
+
cur = p1;
|
|
229
|
+
return api;
|
|
230
|
+
}
|
|
231
|
+
throw new Error("pathProfile: arcTo needs a via [x,y] or an { r, sweep?, large? } arc spec");
|
|
232
|
+
},
|
|
173
233
|
cubicTo(to, c1, c2) {
|
|
174
|
-
|
|
234
|
+
const p = fin2(to, "cubicTo point");
|
|
235
|
+
segments.push({ to: p, c1: fin2(c1, "cubicTo c1"), c2: fin2(c2, "cubicTo c2") });
|
|
236
|
+
cur = p;
|
|
175
237
|
return api;
|
|
176
238
|
},
|
|
177
239
|
close() {
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// resolve against `defaults`, which produce a control that silently does nothing.
|
|
5
5
|
import { err, warn } from "./finding.js";
|
|
6
6
|
import { suggest } from "../geometry/op-options.js";
|
|
7
|
+
import { jsonValueProblem } from "../panel/json-value.js";
|
|
7
8
|
import { fieldsFor, authorFieldsFor, WIDGET_TYPES, GROUP_FIELDS, PRESET_FIELDS, SECTION_FIELDS, normalizeOptions } from "../panel/widget-specs.js";
|
|
8
9
|
import { sectionRenders, desugar } from "../panel/legacy.js";
|
|
9
10
|
import { buildTree, WHEN_OPS } from "../panel/model.js";
|
|
@@ -253,6 +254,9 @@ export const SCHEMA_RULES = [
|
|
|
253
254
|
const defaults = part.defaults;
|
|
254
255
|
return collectDescriptors(part)
|
|
255
256
|
.filter(({ container }) => !container)
|
|
257
|
+
// A custom control's OWN key may hold a JSON value; custom-default-not-json
|
|
258
|
+
// (below) is its rule. Its `keys` stay under this one via their own controls.
|
|
259
|
+
.filter(({ d }) => d.type !== "custom")
|
|
256
260
|
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
257
261
|
.filter(({ d }) => !isEditableValue(defaults[d.key]))
|
|
258
262
|
.map(({ d, path }) => err("control-default-not-primitive",
|
|
@@ -625,4 +629,56 @@ export const SCHEMA_RULES = [
|
|
|
625
629
|
return out;
|
|
626
630
|
},
|
|
627
631
|
},
|
|
632
|
+
{
|
|
633
|
+
// The whole feature is the function: a missing or non-function `widget`
|
|
634
|
+
// renders the error card in the rail and nothing else, every time.
|
|
635
|
+
id: "custom-control-widget-not-function",
|
|
636
|
+
run: ({ part }) => collectDescriptors(part)
|
|
637
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom" && typeof d.widget !== "function")
|
|
638
|
+
.map(({ d, path }) => err("custom-control-widget-not-function",
|
|
639
|
+
`custom control "${d.key}" has no \`widget\` function`,
|
|
640
|
+
"Set `widget` to a function `(host) => …` — usually imported from a sibling file of the part — that draws into `host.el`. See \"Custom controls\" in AUTHORING-PARTS.md.",
|
|
641
|
+
`${path}.widget`)),
|
|
642
|
+
},
|
|
643
|
+
{
|
|
644
|
+
// The one relaxation of control-default-not-primitive: a custom control's
|
|
645
|
+
// key may hold a JSON value (json-value.js), and nothing else — a host that
|
|
646
|
+
// persists panel settings writes it back as a JSON literal.
|
|
647
|
+
id: "custom-default-not-json",
|
|
648
|
+
run: ({ part }) => {
|
|
649
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
650
|
+
const defaults = part.defaults;
|
|
651
|
+
return collectDescriptors(part)
|
|
652
|
+
.filter(({ container, authored, d }) => authored && !container && d.type === "custom")
|
|
653
|
+
.filter(({ d }) => typeof d.key === "string" && Object.hasOwn(defaults, d.key))
|
|
654
|
+
.map(({ d }) => ({ d, problem: jsonValueProblem(defaults[d.key]) }))
|
|
655
|
+
.filter(({ problem }) => problem !== null)
|
|
656
|
+
.map(({ d, problem }) => err("custom-default-not-json",
|
|
657
|
+
`\`defaults.${d.key}\` ${problem}`,
|
|
658
|
+
`Give "${d.key}" a JSON value: numbers, strings, booleans, arrays and plain objects of those, at most 16 KB and 8 levels deep, with no null. A custom control stores and persists its value as JSON, so anything else is silently lost on reload.`,
|
|
659
|
+
`defaults.${d.key}`));
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
{
|
|
663
|
+
// `keys` are params the widget writes besides its own; each needs a
|
|
664
|
+
// default or the write lands on a key the build never reads.
|
|
665
|
+
id: "custom-keys-not-in-defaults",
|
|
666
|
+
run: ({ part }) => {
|
|
667
|
+
if (!isPlainObject(part?.defaults)) return [];
|
|
668
|
+
const known = defaultKeys(part);
|
|
669
|
+
const out = [];
|
|
670
|
+
for (const { d, path, container, authored } of collectDescriptors(part)) {
|
|
671
|
+
if (!authored || container || d.type !== "custom" || !Array.isArray(d.keys)) continue;
|
|
672
|
+
d.keys.forEach((k, i) => {
|
|
673
|
+
if (typeof k === "string" && known.has(k)) return;
|
|
674
|
+
const hint = typeof k === "string" ? suggest(k, [...known]) : null;
|
|
675
|
+
out.push(err("custom-keys-not-in-defaults",
|
|
676
|
+
`custom control "${d.key}" lists key "${k}", which is not in \`defaults\``,
|
|
677
|
+
`Add "${k}" to \`defaults\`${hint ? `, or correct it to "${hint}"` : ""} — a key the widget writes must exist for the build to read it.`,
|
|
678
|
+
`${path}.keys[${i}]`));
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
return out;
|
|
682
|
+
},
|
|
683
|
+
},
|
|
628
684
|
];
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
// closure (test/lint-purity.test.js) so lintPart keeps running in Node, the
|
|
13
13
|
// browser sandbox iframe, and Deno.
|
|
14
14
|
//
|
|
15
|
+
// The one exception is json-value.js, which itself imports nothing — the
|
|
16
|
+
// shared predicate for a `type: "custom"` control's value.
|
|
17
|
+
//
|
|
15
18
|
// KNOWN BLIND SPOT — regex literals are not tokenized: a `/[/*]/` or `/x\/y/`
|
|
16
19
|
// reads as a comment opener and can blank the rest of the file, and a regex
|
|
17
20
|
// containing a quote can likewise derail string skipping.
|
|
@@ -19,6 +22,8 @@
|
|
|
19
22
|
// FALSE NEGATIVES — a derailed scan finds no defaults literal and no tokens,
|
|
20
23
|
// so a rule says nothing rather than something wrong.
|
|
21
24
|
|
|
25
|
+
import { isJsonValue, CUSTOM_VALUE_MAX_DEPTH } from "../panel/json-value.js";
|
|
26
|
+
|
|
22
27
|
// Span of the object literal after the first `defaults:` key (indices into
|
|
23
28
|
// `source`, end exclusive, covering `{...}`). String- and comment-aware so a
|
|
24
29
|
// "defaults: {" inside a string or comment can't fool the scan.
|
|
@@ -191,8 +196,9 @@ function decodeStringLiteral(raw) {
|
|
|
191
196
|
// into a different spelling of themselves.
|
|
192
197
|
const NUMBER_RE = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?$/;
|
|
193
198
|
|
|
194
|
-
// One value's source text → { value }, or null when it is not a
|
|
195
|
-
//
|
|
199
|
+
// One value's source text → { value }, or null when it is not a literal this
|
|
200
|
+
// module can read AND write back. Primitives first; then the JSON-literal
|
|
201
|
+
// grammar a `type: "custom"` control's value is written in.
|
|
196
202
|
function readValue(raw) {
|
|
197
203
|
if (raw === "true") return { value: true };
|
|
198
204
|
if (raw === "false") return { value: false };
|
|
@@ -205,6 +211,7 @@ function readValue(raw) {
|
|
|
205
211
|
const s = decodeStringLiteral(raw);
|
|
206
212
|
return s === null ? null : { value: s };
|
|
207
213
|
}
|
|
214
|
+
if (q === "[" || q === "{") return readJsonLiteral(raw);
|
|
208
215
|
return null;
|
|
209
216
|
}
|
|
210
217
|
|
|
@@ -220,6 +227,113 @@ function readKey(text, i, end) {
|
|
|
220
227
|
return m ? { key: m[0], next: i + m[0].length } : null;
|
|
221
228
|
}
|
|
222
229
|
|
|
230
|
+
// --- JSON literals ----------------------------------------------------------
|
|
231
|
+
//
|
|
232
|
+
// The grammar a custom control's owned value is spelled in inside `defaults`:
|
|
233
|
+
// object and array literals of primitives, nested; bare or quoted keys; JS
|
|
234
|
+
// string escapes (the same decoder as above); decimal numbers; true/false;
|
|
235
|
+
// trailing commas; whitespace. NOTHING else — no comments inside the span, no
|
|
236
|
+
// expressions, identifiers, templates, `null`, computed keys, spreads — so
|
|
237
|
+
// every value this reads, writeJsonLiteral can write back and this can read
|
|
238
|
+
// again. A parse failure is null, never a throw: the entry simply stays
|
|
239
|
+
// unreadable, exactly as an expression does.
|
|
240
|
+
//
|
|
241
|
+
// `depth` is threaded through value/array/object and checked BEFORE
|
|
242
|
+
// recursing into a container, so pathological nesting (thousands of `[`)
|
|
243
|
+
// fails fast during parsing instead of blowing the JS call stack — a
|
|
244
|
+
// RangeError would escape readJsonLiteral's try/catch (it only catches
|
|
245
|
+
// JsonLiteralError) and reach a host outside runRules' own try/catch. The
|
|
246
|
+
// threshold matches json-value.js's own depth check exactly (same "depth at
|
|
247
|
+
// which this container sits" numbering), so this is a fast-fail that agrees
|
|
248
|
+
// with the post-parse isJsonValue call below, never a stricter or looser one.
|
|
249
|
+
|
|
250
|
+
class JsonLiteralError extends Error {}
|
|
251
|
+
|
|
252
|
+
function jsonParser(text) {
|
|
253
|
+
let i = 0;
|
|
254
|
+
const fail = () => { throw new JsonLiteralError(); };
|
|
255
|
+
const ws = () => { while (i < text.length && /\s/.test(text[i])) i++; };
|
|
256
|
+
const value = (depth = 0) => {
|
|
257
|
+
ws();
|
|
258
|
+
const c = text[i];
|
|
259
|
+
if (c === "{") return object(depth);
|
|
260
|
+
if (c === "[") return array(depth);
|
|
261
|
+
if (c === '"' || c === "'") {
|
|
262
|
+
const end = skipQuoted(text, i, text.length);
|
|
263
|
+
const s = decodeStringLiteral(text.slice(i, end));
|
|
264
|
+
if (s === null) fail();
|
|
265
|
+
i = end;
|
|
266
|
+
return s;
|
|
267
|
+
}
|
|
268
|
+
const m = /^-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?/.exec(text.slice(i));
|
|
269
|
+
if (m) { i += m[0].length; const n = Number(m[0]); if (!Number.isFinite(n)) fail(); return n; }
|
|
270
|
+
if (text.startsWith("true", i)) { i += 4; return true; }
|
|
271
|
+
if (text.startsWith("false", i)) { i += 5; return false; }
|
|
272
|
+
return fail();
|
|
273
|
+
};
|
|
274
|
+
const array = (depth) => {
|
|
275
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
276
|
+
i++; // [
|
|
277
|
+
const out = [];
|
|
278
|
+
for (;;) {
|
|
279
|
+
ws();
|
|
280
|
+
if (text[i] === "]") { i++; return out; }
|
|
281
|
+
if (out.length) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "]") { i++; return out; } }
|
|
282
|
+
out.push(value(depth + 1));
|
|
283
|
+
}
|
|
284
|
+
};
|
|
285
|
+
const object = (depth) => {
|
|
286
|
+
if (depth >= CUSTOM_VALUE_MAX_DEPTH) fail();
|
|
287
|
+
i++; // {
|
|
288
|
+
const out = {};
|
|
289
|
+
let n = 0;
|
|
290
|
+
for (;;) {
|
|
291
|
+
ws();
|
|
292
|
+
if (text[i] === "}") { i++; return out; }
|
|
293
|
+
if (n) { if (text[i] !== ",") fail(); i++; ws(); if (text[i] === "}") { i++; return out; } }
|
|
294
|
+
const k = readKey(text, i, text.length);
|
|
295
|
+
if (!k) fail();
|
|
296
|
+
i = k.next;
|
|
297
|
+
ws();
|
|
298
|
+
if (text[i] !== ":") fail();
|
|
299
|
+
i++;
|
|
300
|
+
const v = value(depth + 1);
|
|
301
|
+
Object.defineProperty(out, k.key, { value: v, enumerable: true, writable: true, configurable: true });
|
|
302
|
+
n++;
|
|
303
|
+
}
|
|
304
|
+
};
|
|
305
|
+
return { value, done: () => { ws(); return i === text.length; } };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// An array or object literal's source text → { value }, or null. Applies the
|
|
309
|
+
// custom-value caps (json-value.js) so a literal lint accepts is one the panel
|
|
310
|
+
// would accept too.
|
|
311
|
+
export function readJsonLiteral(text) {
|
|
312
|
+
if (typeof text !== "string") return null;
|
|
313
|
+
const first = text.trimStart()[0];
|
|
314
|
+
if (first !== "[" && first !== "{") return null;
|
|
315
|
+
try {
|
|
316
|
+
const p = jsonParser(text);
|
|
317
|
+
const v = p.value();
|
|
318
|
+
if (!p.done()) return null;
|
|
319
|
+
return isJsonValue(v) ? { value: v } : null;
|
|
320
|
+
} catch (e) {
|
|
321
|
+
if (e instanceof JsonLiteralError) return null;
|
|
322
|
+
throw e;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// The source text for a JSON value being written into `defaults`: compact
|
|
327
|
+
// when it fits on a line, else indented two spaces deeper than the entry's
|
|
328
|
+
// own indentation (`indent`, the whitespace before the entry's key) so a large
|
|
329
|
+
// layout diffs line by line. Always plain JSON — quoted keys — so the reader
|
|
330
|
+
// above accepts it unchanged.
|
|
331
|
+
export function writeJsonLiteral(value, { indent = "" } = {}) {
|
|
332
|
+
const compact = JSON.stringify(value);
|
|
333
|
+
if (compact.length <= 80) return compact;
|
|
334
|
+
return JSON.stringify(value, null, 2).replace(/\n/g, `\n${indent}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
223
337
|
// Split `{ … }` into entries in source order. `readable` says whether the
|
|
224
338
|
// value was interpreted; `raw` is its exact source text either way, and
|
|
225
339
|
// valueStart/valueEnd are its span (indices into `text`), which is what a
|