@promptctl/cc-candybar 1.30.0 → 1.32.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/dist/index.mjs +80 -80
- package/package.json +5 -5
- package/schema/cc-candybar.schema.json +65 -0
- package/src/click/wire.ts +9 -0
- package/src/config/action.ts +65 -2
- package/src/config/dsl-loader.ts +18 -1
- package/src/config/edit-chrome.ts +329 -0
- package/src/config/layout-ops.ts +21 -0
- package/src/config/loader/actions.ts +97 -13
- package/src/config/loader/cross-ref.ts +29 -9
- package/src/config/loader/edit-mode.ts +111 -0
- package/src/daemon/config-overrides-store.ts +308 -17
- package/src/daemon/verbs/config-validators.ts +39 -1
- package/src/daemon/verbs/index.ts +40 -2
- package/src/dsl/render.ts +10 -1
- package/src/render/action.ts +76 -2
- package/src/render/picker.ts +71 -38
|
@@ -145,6 +145,8 @@ const ACTION_ARMS: Record<ActionKey, ArmParse<ActionDecl>> = {
|
|
|
145
145
|
copy: templateArm("copy"),
|
|
146
146
|
open: templateArm("open"),
|
|
147
147
|
reset: resetArm,
|
|
148
|
+
undo: markerArm("undo"),
|
|
149
|
+
redo: markerArm("redo"),
|
|
148
150
|
};
|
|
149
151
|
|
|
150
152
|
// [LAW:one-source-of-truth] A copy/open action emits the closed single-key
|
|
@@ -170,6 +172,8 @@ function actionDeclJson(): JsonNode {
|
|
|
170
172
|
templateArmJson("copy"),
|
|
171
173
|
templateArmJson("open"),
|
|
172
174
|
templateArmJson("reset"),
|
|
175
|
+
markerArmJson("undo"),
|
|
176
|
+
markerArmJson("redo"),
|
|
173
177
|
],
|
|
174
178
|
};
|
|
175
179
|
}
|
|
@@ -228,6 +232,47 @@ function resetArm(
|
|
|
228
232
|
return key === null ? null : { reset: key };
|
|
229
233
|
}
|
|
230
234
|
|
|
235
|
+
// [LAW:one-type-per-behavior] `undo`/`redo` are copy/open/reset's shape one
|
|
236
|
+
// step further reduced: a single required key whose only legal VALUE is the
|
|
237
|
+
// literal `true` (mirrors intMarkerSpec — a marker, not data), because there
|
|
238
|
+
// is no key to name: the history they step is one global stack over the
|
|
239
|
+
// whole overrides layer, not a per-target write. `function`, not a const
|
|
240
|
+
// arrow, so ACTION_ARMS above (built before this declaration in source
|
|
241
|
+
// order) can reference it directly via hoisting.
|
|
242
|
+
function markerArm(key: "undo" | "redo"): ArmParse<ActionDecl> {
|
|
243
|
+
return (ctx, path, raw) => {
|
|
244
|
+
for (const k of Object.keys(raw)) {
|
|
245
|
+
if (k !== key)
|
|
246
|
+
issue(
|
|
247
|
+
ctx,
|
|
248
|
+
`${path}.${k}`,
|
|
249
|
+
`Unknown key "${k}" on a ${key} action. Expected only: ${key}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
if (raw[key] !== true) {
|
|
253
|
+
issue(
|
|
254
|
+
ctx,
|
|
255
|
+
`${path}.${key}`,
|
|
256
|
+
`${key} must be the literal true (it takes no key — it steps the ONE global history over the whole overrides layer), got ${describeValue(raw[key])}`,
|
|
257
|
+
);
|
|
258
|
+
return null;
|
|
259
|
+
}
|
|
260
|
+
return { [key]: true } as unknown as ActionDecl;
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// [LAW:one-source-of-truth] Mirrors templateArmJson's shape one level
|
|
265
|
+
// narrower: the value schema is `const: true`, not `type: string` — a
|
|
266
|
+
// marker action carries no data, on the wire or in the schema.
|
|
267
|
+
function markerArmJson(key: "undo" | "redo"): JsonNode {
|
|
268
|
+
return {
|
|
269
|
+
type: "object",
|
|
270
|
+
properties: { [key]: { const: true } },
|
|
271
|
+
required: [key],
|
|
272
|
+
additionalProperties: false,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
231
276
|
// ─── The `set` value-source sub-union ────────────────────────────────────────
|
|
232
277
|
|
|
233
278
|
// [LAW:single-enforcer] A set-state URL path segment must be a non-empty,
|
|
@@ -343,6 +388,20 @@ const INSERT_SEGMENT_FIELDS: FieldSpecMap<{
|
|
|
343
388
|
anchor: layoutNameSpec("anchor"),
|
|
344
389
|
relation: relationSpec(),
|
|
345
390
|
};
|
|
391
|
+
// [LAW:one-type-per-behavior] `insertSegmentFrom`'s payload mirrors
|
|
392
|
+
// `insertSegment`'s verbatim except the segment name is a `from`-shaped
|
|
393
|
+
// OptionDomain (fromSpec, the SAME field `set`/`persist … from` already
|
|
394
|
+
// validate) instead of a literal layout name — the "to" vs "from" split every
|
|
395
|
+
// other value source already draws, one arm over.
|
|
396
|
+
const INSERT_SEGMENT_FROM_FIELDS: FieldSpecMap<{
|
|
397
|
+
insertSegmentFrom: OptionDomain;
|
|
398
|
+
anchor: string;
|
|
399
|
+
relation: "before" | "after";
|
|
400
|
+
}> = {
|
|
401
|
+
insertSegmentFrom: fromSpec("persist"),
|
|
402
|
+
anchor: layoutNameSpec("anchor"),
|
|
403
|
+
relation: relationSpec(),
|
|
404
|
+
};
|
|
346
405
|
|
|
347
406
|
// [LAW:types-are-the-program] A bounded step is fully described by an integer
|
|
348
407
|
// domain (min < max) and a non-zero integer increment (`by`; negative for a
|
|
@@ -395,12 +454,25 @@ interface ValueSourceArm {
|
|
|
395
454
|
readonly parse: ArmParse<Partial<ActionDecl>>;
|
|
396
455
|
}
|
|
397
456
|
|
|
457
|
+
// [LAW:no-mode-explosion] `detectKeys` narrows WHICH of an arm's fields the
|
|
458
|
+
// present-count dispatch keys off, independent of `allowed`/`label` (still
|
|
459
|
+
// the full field set — what the arm PERMITS and is NAMED by never changes).
|
|
460
|
+
// Every arm before insertSegmentFrom had a field set disjoint from every
|
|
461
|
+
// other arm's, so `Object.keys(fieldMap)` was a safe default for both jobs
|
|
462
|
+
// at once. insertSegmentFrom breaks that: it shares `anchor`/`relation` with
|
|
463
|
+
// insertSegment (same POSITION shape, different segment-name SOURCE), so
|
|
464
|
+
// dispatching on the full set would make an ordinary `insertSegment` action
|
|
465
|
+
// spuriously match both arms via those shared keys. Pass the true
|
|
466
|
+
// discriminator (the field no sibling arm carries) here; omit it when the
|
|
467
|
+
// field set already is disjoint from every sibling, as it is everywhere else.
|
|
398
468
|
function valueSourceArm<P extends object>(
|
|
399
469
|
discriminator: "set" | "persist",
|
|
400
470
|
fieldMap: FieldSpecMap<P>,
|
|
401
|
-
|
|
471
|
+
checks: ReadonlyArray<Refinement<P>> = [],
|
|
472
|
+
detectKeys?: readonly string[],
|
|
402
473
|
): ValueSourceArm {
|
|
403
|
-
const
|
|
474
|
+
const fullKeys = Object.keys(fieldMap);
|
|
475
|
+
const detect = detectKeys ?? fullKeys;
|
|
404
476
|
const inner: ArmParse<P> = (ctx, path, raw) =>
|
|
405
477
|
fields(ctx, fieldMap, path, raw);
|
|
406
478
|
const source = objectJson(fieldMap) as {
|
|
@@ -409,8 +481,8 @@ function valueSourceArm<P extends object>(
|
|
|
409
481
|
};
|
|
410
482
|
return {
|
|
411
483
|
detect,
|
|
412
|
-
allowed: [discriminator, ...
|
|
413
|
-
label:
|
|
484
|
+
allowed: [discriminator, ...fullKeys],
|
|
485
|
+
label: fullKeys.join("/"),
|
|
414
486
|
json: {
|
|
415
487
|
type: "object",
|
|
416
488
|
properties: { [discriminator]: { type: "string" }, ...source.properties },
|
|
@@ -430,7 +502,7 @@ function valueSourceArm<P extends object>(
|
|
|
430
502
|
const SET_ARMS: readonly ValueSourceArm[] = [
|
|
431
503
|
valueSourceArm("set", TO_FIELDS_SET),
|
|
432
504
|
valueSourceArm("set", FROM_FIELDS_SET),
|
|
433
|
-
valueSourceArm("set", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
505
|
+
valueSourceArm("set", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
434
506
|
valueSourceArm("set", INT_FIELDS),
|
|
435
507
|
valueSourceArm("set", CYCLE_FIELDS_SET),
|
|
436
508
|
];
|
|
@@ -442,10 +514,21 @@ const SET_ARMS: readonly ValueSourceArm[] = [
|
|
|
442
514
|
const PERSIST_ARMS: readonly ValueSourceArm[] = [
|
|
443
515
|
valueSourceArm("persist", TO_FIELDS_PERSIST),
|
|
444
516
|
valueSourceArm("persist", FROM_FIELDS_PERSIST),
|
|
445
|
-
valueSourceArm("persist", BOUNDED_FIELDS, minLessThanMax, byNonZero),
|
|
517
|
+
valueSourceArm("persist", BOUNDED_FIELDS, [minLessThanMax, byNonZero]),
|
|
446
518
|
valueSourceArm("persist", CYCLE_FIELDS_PERSIST),
|
|
447
519
|
valueSourceArm("persist", REMOVE_SEGMENT_FIELDS),
|
|
448
|
-
|
|
520
|
+
// [LAW:no-mode-explosion] Both insertSegment arms narrow detectKeys to
|
|
521
|
+
// their own discriminating field — see valueSourceArm's own comment. They
|
|
522
|
+
// share "anchor"/"relation" (same position shape, different segment-name
|
|
523
|
+
// source), so dispatching on the full field set would make EITHER arm
|
|
524
|
+
// spuriously match an action declaring the other.
|
|
525
|
+
valueSourceArm("persist", INSERT_SEGMENT_FIELDS, [], ["insertSegment"]),
|
|
526
|
+
valueSourceArm(
|
|
527
|
+
"persist",
|
|
528
|
+
INSERT_SEGMENT_FROM_FIELDS,
|
|
529
|
+
[],
|
|
530
|
+
["insertSegmentFrom"],
|
|
531
|
+
),
|
|
449
532
|
];
|
|
450
533
|
|
|
451
534
|
// [LAW:one-source-of-truth] The clause list, not the joined string, is the
|
|
@@ -466,6 +549,7 @@ function valueSourceClauses(discriminator: "set" | "persist"): string[] {
|
|
|
466
549
|
clauses.push(
|
|
467
550
|
`"removeSegment" (remove a named segment from the layout)`,
|
|
468
551
|
`"insertSegment"/"anchor"/"relation" (insert a named segment before/after an existing one)`,
|
|
552
|
+
`"insertSegmentFrom"/"anchor"/"relation" (insert a segment PICKED from an option domain before/after an existing one)`,
|
|
469
553
|
);
|
|
470
554
|
}
|
|
471
555
|
return clauses;
|
|
@@ -590,7 +674,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
590
674
|
const at = `${path}.${field}`;
|
|
591
675
|
if (typeof from === "string") {
|
|
592
676
|
if (from === "") {
|
|
593
|
-
issue(ctx, at,
|
|
677
|
+
issue(ctx, at, `${field} must be a non-empty domain name`);
|
|
594
678
|
return undefined;
|
|
595
679
|
}
|
|
596
680
|
return from;
|
|
@@ -601,7 +685,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
601
685
|
issue(
|
|
602
686
|
ctx,
|
|
603
687
|
at,
|
|
604
|
-
|
|
688
|
+
`${field} must name a domain (a non-empty string) or declare an inline domain (a non-empty array of values)`,
|
|
605
689
|
);
|
|
606
690
|
return undefined;
|
|
607
691
|
}
|
|
@@ -609,7 +693,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
609
693
|
issue(
|
|
610
694
|
ctx,
|
|
611
695
|
at,
|
|
612
|
-
|
|
696
|
+
`${field} array members must be non-empty — an empty value cannot be delivered on the ${wire} wire`,
|
|
613
697
|
);
|
|
614
698
|
return undefined;
|
|
615
699
|
}
|
|
@@ -618,7 +702,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
618
702
|
issue(
|
|
619
703
|
ctx,
|
|
620
704
|
at,
|
|
621
|
-
|
|
705
|
+
`${field} array member(s) ${slashed.map((m) => `"${m}"`).join(", ")} contain "/" — ${discriminator} values must be slash-free`,
|
|
622
706
|
);
|
|
623
707
|
return undefined;
|
|
624
708
|
}
|
|
@@ -626,7 +710,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
626
710
|
issue(
|
|
627
711
|
ctx,
|
|
628
712
|
at,
|
|
629
|
-
|
|
713
|
+
`${field} array members must be unique — a duplicated value would render the same picker option twice`,
|
|
630
714
|
);
|
|
631
715
|
return undefined;
|
|
632
716
|
}
|
|
@@ -635,7 +719,7 @@ function fromSpec(discriminator: "set" | "persist"): FieldSpec<OptionDomain> {
|
|
|
635
719
|
issue(
|
|
636
720
|
ctx,
|
|
637
721
|
at,
|
|
638
|
-
|
|
722
|
+
`${field} must be a domain name (a string) or an inline domain (an array of strings), got ${describeValue(from)}`,
|
|
639
723
|
);
|
|
640
724
|
return undefined;
|
|
641
725
|
},
|
|
@@ -15,8 +15,10 @@ import {
|
|
|
15
15
|
} from "../dsl-types.js";
|
|
16
16
|
import {
|
|
17
17
|
actionBindsPersist,
|
|
18
|
+
actionBindsRedo,
|
|
18
19
|
actionBindsReset,
|
|
19
20
|
actionBindsSet,
|
|
21
|
+
actionBindsUndo,
|
|
20
22
|
type ActionDecl,
|
|
21
23
|
} from "../action.js";
|
|
22
24
|
import {
|
|
@@ -394,10 +396,14 @@ function checkPresetRootOpsTarget(
|
|
|
394
396
|
if (discriminator === "reset") return;
|
|
395
397
|
const hasRemove = "removeSegment" in a;
|
|
396
398
|
const hasInsert = "insertSegment" in a;
|
|
397
|
-
|
|
399
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's domain-sourced
|
|
400
|
+
// sibling — the segment name is picked at render, so only `anchor` (still
|
|
401
|
+
// literal at author time) needs the declared-segment check below.
|
|
402
|
+
const hasInsertFrom = "insertSegmentFrom" in a;
|
|
403
|
+
if (!hasRemove && !hasInsert && !hasInsertFrom) {
|
|
398
404
|
ctx.issues.push({
|
|
399
405
|
path: at,
|
|
400
|
-
message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment" or "
|
|
406
|
+
message: `actions.${name}: "${key}" is a "presets.<name>.rootOps" target and can only be paired with "removeSegment", "insertSegment", or "insertSegmentFrom" (not "to"/"from"/"cycle"/bounded — those have no meaning as a tree op)`,
|
|
401
407
|
line,
|
|
402
408
|
});
|
|
403
409
|
return;
|
|
@@ -427,6 +433,13 @@ function checkPresetRootOpsTarget(
|
|
|
427
433
|
});
|
|
428
434
|
}
|
|
429
435
|
}
|
|
436
|
+
if (hasInsertFrom && "insertSegmentFrom" in a && missing(a.anchor)) {
|
|
437
|
+
ctx.issues.push({
|
|
438
|
+
path: at,
|
|
439
|
+
message: `actions.${name}: anchor "${a.anchor}" is not a declared segment (have: ${Object.keys(cfg.segments).join(", ")})`,
|
|
440
|
+
line,
|
|
441
|
+
});
|
|
442
|
+
}
|
|
430
443
|
}
|
|
431
444
|
|
|
432
445
|
function hasStateKind(cfg: DslConfig): boolean {
|
|
@@ -442,15 +455,22 @@ function hasStateKind(cfg: DslConfig): boolean {
|
|
|
442
455
|
return false;
|
|
443
456
|
}
|
|
444
457
|
|
|
445
|
-
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
446
|
-
// reset-config click — and so needs session.id — when any
|
|
447
|
-
// a `set` (literal/option/bounded/cycle), a `persist`
|
|
448
|
-
// twin),
|
|
449
|
-
// the
|
|
450
|
-
//
|
|
458
|
+
// [LAW:dataflow-not-control-flow] A config emits a set-state, set-config,
|
|
459
|
+
// reset-config, undo, OR redo click — and so needs session.id — when any
|
|
460
|
+
// declared action is a `set` (literal/option/bounded/cycle), a `persist`
|
|
461
|
+
// (its config-overrides twin), a `reset` (persist's gated undo), or an
|
|
462
|
+
// `undo`/`redo` (the overrides layer's global history step) — all five
|
|
463
|
+
// carry session.id on the wire for click-error surfacing (an empty history
|
|
464
|
+
// stack is a loud, session-scoped miss, not a silent no-op). copy/open
|
|
465
|
+
// actions write nothing, so they embed no session.id.
|
|
451
466
|
function hasActionSetAction(cfg: DslConfig): boolean {
|
|
452
467
|
return Object.values(cfg.actions).some(
|
|
453
|
-
(a) =>
|
|
468
|
+
(a) =>
|
|
469
|
+
actionBindsSet(a) ||
|
|
470
|
+
actionBindsPersist(a) ||
|
|
471
|
+
actionBindsReset(a) ||
|
|
472
|
+
actionBindsUndo(a) ||
|
|
473
|
+
actionBindsRedo(a),
|
|
454
474
|
);
|
|
455
475
|
}
|
|
456
476
|
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// [LAW:one-source-of-truth] brandon-layout-edit-2gc.3's TOGGLE half of edit
|
|
2
|
+
// mode — the disclosure primitive's third body-kind, one register down from
|
|
3
|
+
// group sugar and `{{ menu }}`: where those two synthesize a whole trigger +
|
|
4
|
+
// body, edit mode synthesizes only the on/off state + toggle ACTION here
|
|
5
|
+
// (`edit.mode` / `edit.toggle`), so a hand-authored `{{ action "edit.toggle"
|
|
6
|
+
// "✎" }}` cross-ref-checks and compiles exactly like any other action — no
|
|
7
|
+
// bespoke "this action always exists" carve-out anywhere downstream. The
|
|
8
|
+
// per-segment +/- CHROME is a separate, LATER pass
|
|
9
|
+
// (src/config/edit-chrome.ts) that runs on the fully merged, preset-resolved,
|
|
10
|
+
// rootOps-replayed config (inside validateConfig, not here) because it needs
|
|
11
|
+
// data — which segments are in which preset's CURRENT tree — that does not
|
|
12
|
+
// exist yet at this per-file parse stage. Splitting the two halves across two
|
|
13
|
+
// synthesis points is not incidental: the toggle is authorable/cross-ref-able
|
|
14
|
+
// content (like a group's name or a menu's apply action), the chrome is
|
|
15
|
+
// derived data (like a group's lowered body), and each belongs at the stage
|
|
16
|
+
// that has what it needs.
|
|
17
|
+
//
|
|
18
|
+
// [LAW:carrying-cost] DEMAND-DRIVEN, not unconditional — this is the one place
|
|
19
|
+
// this pass diverges from group/menu synthesis's OWN precedent of "reserve
|
|
20
|
+
// unconditionally, synthesize on demand" and leans fully into the "on demand"
|
|
21
|
+
// half: a config that references `{{ action "edit.toggle" … }}` nowhere gets
|
|
22
|
+
// NEITHER the toggle var/action NOR (edit-chrome.ts checks for the SAME
|
|
23
|
+
// action's presence) any per-segment chrome. This matters concretely, not just
|
|
24
|
+
// as a purity concern — `edit.mode` is a `state` variable and `edit.toggle` is
|
|
25
|
+
// a `set` action, and cross-ref.ts requires a global `session.id` variable the
|
|
26
|
+
// instant ANY state var or set action exists anywhere in a config. Synthesizing
|
|
27
|
+
// either unconditionally would force session.id onto every purely-static,
|
|
28
|
+
// non-interactive bar in the corpus — exactly the regression an early version
|
|
29
|
+
// of this pass caused. The reserved namespace stays reserved unconditionally
|
|
30
|
+
// (mirroring reservedNamespaceCollisions' own contract); only the SYNTHESIS is
|
|
31
|
+
// conditional.
|
|
32
|
+
|
|
33
|
+
import { createEngine } from "@promptctl/go-template-js";
|
|
34
|
+
import type { Mutable, ValidateCtx } from "./validate-core.js";
|
|
35
|
+
import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
|
|
36
|
+
import type { ActionDecl } from "../action.js";
|
|
37
|
+
import {
|
|
38
|
+
DISCLOSURE_CLOSED,
|
|
39
|
+
disclosureCycleAction,
|
|
40
|
+
disclosureStateVar,
|
|
41
|
+
} from "../disclosure.js";
|
|
42
|
+
import { reservedNamespaceCollisions } from "./reserved-namespace.js";
|
|
43
|
+
|
|
44
|
+
// [LAW:one-source-of-truth] The reserved namespace every edit-mode artifact —
|
|
45
|
+
// this toggle AND edit-chrome.ts's per-position +/- actions/segments — lives
|
|
46
|
+
// under, mirroring `groups.`/`menus.`. Exported so edit-chrome.ts's LATER
|
|
47
|
+
// synthesis (and its `isChromeExempt` exclusion of edit-mode's own chrome
|
|
48
|
+
// from being treated as ordinary, removable/addable content) reads the same
|
|
49
|
+
// string, never a second copy.
|
|
50
|
+
export const EDIT_NS = "edit.";
|
|
51
|
+
|
|
52
|
+
// [LAW:single-enforcer] The SessionState key edit mode's on/off state lives
|
|
53
|
+
// at, and the toggle action's identity member. Both edit-chrome.ts (every
|
|
54
|
+
// synthesized affordance's `when` gate) and a hand-authored trigger segment
|
|
55
|
+
// read/write these same two names — one declaration, no drift.
|
|
56
|
+
export const EDIT_MODE_KEY = "edit.mode";
|
|
57
|
+
export const EDIT_TOGGLE_ACTION = "edit.toggle";
|
|
58
|
+
export const EDIT_MODE_OPEN = "open";
|
|
59
|
+
|
|
60
|
+
// [LAW:one-source-of-truth] The predicate every synthesized +/- chrome
|
|
61
|
+
// segment gates on — spelled once here so edit-chrome.ts never hand-rolls
|
|
62
|
+
// the template string a second time.
|
|
63
|
+
export const EDIT_MODE_GATE = `{{ eq .${EDIT_MODE_KEY} "${EDIT_MODE_OPEN}" }}`;
|
|
64
|
+
|
|
65
|
+
// [LAW:single-enforcer] The ONE detector for "does this file want edit mode":
|
|
66
|
+
// a literal `{{ action "edit.toggle" … }}` call somewhere a segment's
|
|
67
|
+
// template/bg/fg can reach — the SAME AST-based approach
|
|
68
|
+
// menu-synth.ts's segmentReferencesMenu uses (robust against whitespace,
|
|
69
|
+
// pipelines, and lookalike text a source-string scan would false-positive
|
|
70
|
+
// or false-negative on), one function name over. A bare engine purely for
|
|
71
|
+
// introspection: it never evaluates, so a malformed template simply yields
|
|
72
|
+
// no match here (registerDslConfig re-parses and reports the real error;
|
|
73
|
+
// [LAW:no-silent-failure] this pass just isn't the one that reports it).
|
|
74
|
+
function referencesEditToggle(template: string): boolean {
|
|
75
|
+
const engine = createEngine<string>({ fromString: (s) => s });
|
|
76
|
+
try {
|
|
77
|
+
return engine
|
|
78
|
+
.parse(template)
|
|
79
|
+
.referencedCalls()
|
|
80
|
+
.some((c) => c.name === "action" && c.args[0] === EDIT_TOGGLE_ACTION);
|
|
81
|
+
} catch {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function fileWantsEditMode(out: Readonly<RawDslConfig>): boolean {
|
|
87
|
+
for (const seg of Object.values(out.segments ?? {})) {
|
|
88
|
+
for (const field of [seg.template, seg.bg, seg.fg] as const) {
|
|
89
|
+
if (typeof field === "string" && referencesEditToggle(field)) {
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function synthesizeEditModeToggle(
|
|
98
|
+
ctx: ValidateCtx,
|
|
99
|
+
out: Mutable<RawDslConfig>,
|
|
100
|
+
): void {
|
|
101
|
+
reservedNamespaceCollisions(ctx, out, EDIT_NS, "edit mode");
|
|
102
|
+
if (!fileWantsEditMode(out)) return;
|
|
103
|
+
const variables: Record<string, VariableDecl> = {
|
|
104
|
+
[EDIT_MODE_KEY]: disclosureStateVar(EDIT_MODE_KEY, DISCLOSURE_CLOSED),
|
|
105
|
+
};
|
|
106
|
+
const actions: Record<string, ActionDecl> = {
|
|
107
|
+
[EDIT_TOGGLE_ACTION]: disclosureCycleAction(EDIT_MODE_KEY, EDIT_MODE_OPEN),
|
|
108
|
+
};
|
|
109
|
+
out.variables = { ...(out.variables ?? {}), ...variables };
|
|
110
|
+
out.actions = { ...(out.actions ?? {}), ...actions };
|
|
111
|
+
}
|