@promptctl/cc-candybar 1.8.0 → 1.8.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@promptctl/cc-candybar",
3
- "version": "1.8.0",
3
+ "version": "1.8.2",
4
4
  "description": "Statusline renderer for Claude Code — a JSON5-configurable DSL with daemon-cached data sources, byte-clean palette-aware composition, and OSC8 click verbs.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.mjs",
@@ -85,16 +85,16 @@
85
85
  "typescript": "^5.0.0"
86
86
  },
87
87
  "dependencies": {
88
- "@promptctl/go-template-js": "^0.5.0",
88
+ "@promptctl/go-template-js": "^0.6.0",
89
89
  "@promptctl/rich-js": "^0.6.0",
90
90
  "json5": "^2.2.3",
91
91
  "mobx": "^6.15.0"
92
92
  },
93
93
  "optionalDependencies": {
94
- "@promptctl/cc-candybar-darwin-arm64": "1.8.0",
95
- "@promptctl/cc-candybar-darwin-x64": "1.8.0",
96
- "@promptctl/cc-candybar-linux-x64": "1.8.0",
97
- "@promptctl/cc-candybar-linux-arm64": "1.8.0"
94
+ "@promptctl/cc-candybar-darwin-arm64": "1.8.2",
95
+ "@promptctl/cc-candybar-darwin-x64": "1.8.2",
96
+ "@promptctl/cc-candybar-linux-x64": "1.8.2",
97
+ "@promptctl/cc-candybar-linux-arm64": "1.8.2"
98
98
  },
99
99
  "pnpm": {
100
100
  "supportedArchitectures": {
@@ -71,7 +71,7 @@ export { mergeWithDefault } from "./loader/merge.js";
71
71
  export {
72
72
  extractTemplateRefs,
73
73
  extractActionRefs,
74
- extractPickerRefs,
74
+ extractPickerMenuRefs,
75
75
  } from "./loader/refs.js";
76
76
 
77
77
  // ─── Three-stage pipeline ────────────────────────────────────────────────────
@@ -17,7 +17,7 @@ import { findKeyLine } from "./diagnostics.js";
17
17
  import { isPlainObject, type ValidateCtx } from "./validate-core.js";
18
18
  import {
19
19
  extractActionRefs,
20
- extractPickerRefs,
20
+ extractPickerMenuRefs,
21
21
  extractTemplateRefs,
22
22
  refResolves,
23
23
  } from "./refs.js";
@@ -145,14 +145,16 @@ export function validateCrossReferences(
145
145
  });
146
146
  }
147
147
  }
148
- // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` references two
149
- // named actions — both resolve against the action table at load, same
150
- // existence-check shape as a bare action ref.
151
- for (const pref of extractPickerRefs(tpl)) {
148
+ // [LAW:locality-or-seam] A `{{ picker "apply" "page" … }}` OR `{{ menu
149
+ // "apply" "page" … }}` references two named actions — both resolve against
150
+ // the action table at load, same existence-check shape as a bare action ref.
151
+ // A menu binds the same pair as a picker, so it routes through the SAME
152
+ // check rather than failing only when the disclosure is opened.
153
+ for (const pref of extractPickerMenuRefs(tpl)) {
152
154
  if (!Object.prototype.hasOwnProperty.call(cfg.actions, pref)) {
153
155
  ctx.issues.push({
154
156
  path: `segments.${segName}.${field}`,
155
- message: `${field} references unknown action "${pref}" (in a picker)`,
157
+ message: `${field} references unknown action "${pref}" (in a picker or menu)`,
156
158
  line: findKeyLine(ctx.source, ["segments", segName, field]),
157
159
  });
158
160
  }
@@ -2,8 +2,8 @@
2
2
  // `{{ menu }}` render helper. A menu is the group-accordion mechanism with its
3
3
  // trigger living inside an arbitrary user segment rather than a synthesized
4
4
  // toggle segment — so this emits exactly what group sugar does MINUS the segment
5
- // (the helper IS the trigger): one `state` var per row key (default "closed")
6
- // and one `cycle` action per (row, menu-bearing segment) under the reserved
5
+ // (the helper IS the trigger): one `state` var per menu state key (default
6
+ // "closed") and one `cycle` action per (state key, member) under the reserved
7
7
  // `menus.` namespace. Both land in the raw sections so they merge over the
8
8
  // default and, crucially, so `deriveActionValidators(config.actions)` derives the
9
9
  // click gate from them through the ONE existing path — a menu toggle is gated
@@ -12,12 +12,15 @@
12
12
  // Runs in `parseDslConfig` after group synthesis and after every section parsed,
13
13
  // so the reserved-namespace collision check sees the fully-parsed user sections.
14
14
  //
15
- // [LAW:types-are-the-program] WHICH segments host a menu is read from the parsed
16
- // AST (`referencedFunctions`), not a source-text scan robust against whitespace,
17
- // pipelines, and `.menu`/"menu" lookalikes. Detection bare-parses the segment
18
- // template; a parse failure here is treated as "no menu" because the authoritative
19
- // parse error is surfaced loudly by `registerDslConfig` when it compiles the same
20
- // template (so this pass never swallows a real error, it just declines to guess).
15
+ // [LAW:types-are-the-program] WHICH segments host a menu, and with WHAT apply
16
+ // action + optional shared key, is read from the parsed AST (`referencedCalls`),
17
+ // not a source-text scan robust against whitespace, pipelines, and
18
+ // `.menu`/"menu" lookalikes, and it yields each call's literal string arguments
19
+ // so a menu's identity (member = apply name; key = optional shared key) is the
20
+ // SAME fact the render helper reads from those same argument positions. A parse
21
+ // failure here is treated as "no menu" because the authoritative parse error is
22
+ // surfaced loudly by `registerDslConfig` when it compiles the same template (so
23
+ // this pass never swallows a real error, it just declines to guess).
21
24
 
22
25
  import { createEngine } from "@promptctl/go-template-js";
23
26
  import type { ActionDecl } from "../action.js";
@@ -25,11 +28,15 @@ import type { Mutable, ValidateCtx } from "./validate-core.js";
25
28
  import {
26
29
  MENU_CLOSED,
27
30
  MENU_NS,
28
- forEachSegmentPlacement,
29
31
  menuActionName,
32
+ menuMember,
30
33
  menuStateKey,
31
34
  } from "../menu-keys.js";
32
- import type { RawDslConfig, VariableDecl } from "../dsl-types.js";
35
+ import {
36
+ walkNodes,
37
+ type RawDslConfig,
38
+ type VariableDecl,
39
+ } from "../dsl-types.js";
33
40
  import { findKeyLine } from "./diagnostics.js";
34
41
 
35
42
  // [LAW:single-enforcer] The helper-name a `{{ menu … }}` call uses — the same
@@ -37,17 +44,52 @@ import { findKeyLine } from "./diagnostics.js";
37
44
  // references this function.
38
45
  const MENU_FUNC = "menu";
39
46
 
47
+ // [LAW:types-are-the-program] The `{{ menu }}` argument positions, mirroring the
48
+ // render helper's signature `menu apply page closeOnPick paged key`. Only the
49
+ // apply name (identity member) and the optional shared key (accordion grouping)
50
+ // affect synthesis; page/closeOnPick/paged are render-only and ignored here.
51
+ const ARG_APPLY = 0;
52
+ const ARG_KEY = 4;
53
+
54
+ // [LAW:types-are-the-program] One menu call's identity-bearing arguments. Each
55
+ // arg has three states the synthesis must tell apart: a literal string (usable),
56
+ // `null` (a slot present but non-literal — its value is eval-time, so it cannot
57
+ // be gated at load → author error), and `undefined` (the slot was omitted). The
58
+ // apply slot is required; the key slot is optional (undefined ⇒ independent menu).
59
+ interface MenuCall {
60
+ readonly apply: string | null;
61
+ readonly key: string | null | undefined;
62
+ }
63
+
40
64
  // [LAW:no-defensive-null-guards] A bare engine purely for AST introspection: it
41
65
  // never evaluates, so `fromString` is identity and no funcs are registered (parse
42
66
  // does not resolve function existence — that is an eval-time concern).
43
- function segmentReferencesMenu(template: string): boolean {
67
+ function parseCalls(template: string): readonly MenuCall[] | "parse-failed" {
44
68
  const engine = createEngine<string>({ fromString: (s) => s });
45
69
  try {
46
- return engine.parse(template).referencedFunctions().has(MENU_FUNC);
70
+ return engine
71
+ .parse(template)
72
+ .referencedCalls()
73
+ .filter((c) => c.name === MENU_FUNC)
74
+ .map((c) => ({
75
+ apply: c.args[ARG_APPLY] ?? null,
76
+ // `referencedCalls` reports an omitted positional slot as absent and a
77
+ // present non-literal as null; preserve that distinction.
78
+ key: c.args.length > ARG_KEY ? c.args[ARG_KEY] : undefined,
79
+ }));
47
80
  } catch {
48
81
  // A malformed template can host no usable menu; registerDslConfig re-parses
49
82
  // and reports the real error. [LAW:no-silent-failure] — not swallowed, just
50
83
  // not the place that reports it.
84
+ return "parse-failed";
85
+ }
86
+ }
87
+
88
+ function segmentReferencesMenu(template: string): boolean {
89
+ const engine = createEngine<string>({ fromString: (s) => s });
90
+ try {
91
+ return engine.parse(template).referencedFunctions().has(MENU_FUNC);
92
+ } catch {
51
93
  return false;
52
94
  }
53
95
  }
@@ -77,73 +119,204 @@ export function synthesizeMenuDecls(
77
119
  }
78
120
  }
79
121
 
80
- // [LAW:no-silent-failure] A menu derives its accordion identity from its host
81
- // SEGMENT's tree position; a `{{ define }}` helper is shared and placement-
82
- // agnostic, so a `{{ menu }}` reached through one has no row to key on and would
83
- // never get its backing state var/cycle action synthesized failing at render.
84
- // Reject it loudly at load, pointing the author to inline the menu in a segment.
85
- // [LAW:no-mode-explosion] We reject rather than build helper-call-graph
86
- // resolution speculatively; revisit only if a real shared-menu need appears.
122
+ // [LAW:no-silent-failure] A menu derives its identity from the SEGMENT it sits
123
+ // in (the published segment name) plus its own apply arg; a `{{ define }}`
124
+ // helper is shared across segments, so the synthesis pass which scans each
125
+ // segment's own template cannot see a menu reached only through `{{ template
126
+ // }}` and would never synthesize its backing state var/cycle action, failing at
127
+ // render. Reject it loudly at load, pointing the author to inline the menu.
128
+ // [LAW:no-mode-explosion] We reject rather than resolve the helper call graph
129
+ // speculatively; revisit only if a real shared-menu need appears.
87
130
  for (const [name, body] of Object.entries(out.helpers ?? {})) {
88
131
  if (segmentReferencesMenu(body)) {
89
132
  ctx.issues.push({
90
133
  path: `helpers.${name}`,
91
- message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its accordion identity is derived from the segment's position in the layout, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
134
+ message: `helper "${name}" uses {{ menu }}, but a menu must live directly in a segment template — its identity is derived from the segment it sits in, which a shared helper does not have. Inline the {{ menu }} call into each segment that needs it.`,
92
135
  line: findKeyLine(ctx.source, ["helpers", name]),
93
136
  });
94
137
  }
95
138
  }
96
139
 
97
- if (out.root === undefined) return;
98
140
  const segments = out.segments ?? {};
99
141
 
100
- // [LAW:dataflow-not-control-flow] Memoize detection by segment name a segment
101
- // placed in N rows is parsed once, then each placement reads the cached verdict.
102
- const hostsMenu = new Map<string, boolean>();
103
- const referencesMenu = (segName: string): boolean => {
104
- const cached = hostsMenu.get(segName);
105
- if (cached !== undefined) return cached;
106
- const seg = segments[segName];
107
- const result =
108
- seg !== undefined ? segmentReferencesMenu(seg.template) : false;
109
- hostsMenu.set(segName, result);
110
- return result;
111
- };
112
-
113
- // One state key per row (default "closed"); one cycle action per (row,segment).
114
- const stateKeys = new Map<string, string>(); // stateKey → rowKey (for vars)
115
- const actions: Record<string, ActionDecl> = {};
116
- forEachSegmentPlacement(out.root, (segName, rowKey) => {
117
- if (!referencesMenu(segName)) return;
118
- // [LAW:types-are-the-program] A menu's member name IS its host segment name;
119
- // a segment named exactly the closed-state sentinel would make the cycle
120
- // [closed, "closed"] two identical members, so the toggle could never leave
121
- // the closed state. Reject that one collision at load (the only segment name
122
- // that breaks a menu), rather than silently synthesizing an unopenable menu.
123
- if (segName === MENU_CLOSED) {
142
+ // [LAW:locality-or-seam] A menu publishes its placement ONLY around the segment
143
+ // `template` eval; bg/fg evaluate after that window and a node/segment `when`
144
+ // before it, so a {{ menu }} in any of them throws at render. The template is
145
+ // the menu's one valid seam reject it anywhere else at load, rather than
146
+ // admit a config that parses but crashes on render.
147
+ for (const [segName, seg] of Object.entries(segments)) {
148
+ for (const field of ["bg", "fg", "when"] as const) {
149
+ const tpl = seg[field];
150
+ if (typeof tpl === "string" && segmentReferencesMenu(tpl)) {
151
+ menuIssue(
152
+ ctx,
153
+ `segments.${segName}.${field}`,
154
+ `segment "${segName}" uses {{ menu }} in its "${field}" — a menu is only valid in a segment's "template" (its placement is published only there; "${field}" needs a ${field === "when" ? "predicate" : "color"}). Move the {{ menu }} into the template.`,
155
+ );
156
+ }
157
+ }
158
+ }
159
+
160
+ // One walk over the layout: reject {{ menu }} in node `when` predicates (same
161
+ // template-only rule), and count each segment's placements for the reuse check.
162
+ const placementCounts = new Map<string, number>();
163
+ if (out.root !== undefined) {
164
+ for (const node of walkNodes(out.root)) {
165
+ if (typeof node.when === "string" && segmentReferencesMenu(node.when)) {
166
+ menuIssue(
167
+ ctx,
168
+ "root",
169
+ `a layout node's "when" predicate uses {{ menu }} — a menu is only valid in a segment's "template", not a node predicate. Move it into a segment.`,
170
+ );
171
+ }
172
+ if (node.kind === "segment") {
173
+ placementCounts.set(
174
+ node.name,
175
+ (placementCounts.get(node.name) ?? 0) + 1,
176
+ );
177
+ }
178
+ }
179
+ }
180
+
181
+ // [LAW:types-are-the-program] A menu-bearing segment placed in more than one
182
+ // layout slot is ambiguous: its disclosure open-state is keyed by segment name
183
+ // (one state for the segment), so two placements would share it and a click on
184
+ // one would toggle both. Reject the repeated placement — the ambiguity is
185
+ // unrepresentable, and identity stays name-derived (no placement path threaded
186
+ // into the key). Two independent disclosures = two named segments.
187
+ for (const [segName, seg] of Object.entries(segments)) {
188
+ if (!segmentReferencesMenu(seg.template)) continue;
189
+ if ((placementCounts.get(segName) ?? 0) > 1) {
124
190
  menuIssue(
125
191
  ctx,
126
192
  `segments.${segName}`,
127
- `segment "${segName}" hosts a {{ menu }}, but a menu cannot live in a segment named "${MENU_CLOSED}" that name collides with the menu's closed-state sentinel, leaving the menu unopenable. Rename the segment.`,
193
+ `segment "${segName}" hosts a {{ menu }} and is placed in the layout more than oncea menu's open-state is keyed by segment name, so the copies would share one state (clicking one would toggle both). Give each placement its own named segment.`,
128
194
  );
129
- return;
130
195
  }
131
- const stateKey = menuStateKey(rowKey);
132
- stateKeys.set(stateKey, rowKey);
133
- // [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
134
- // value counts as the first member (the cycle's "unknown ⇒ first" rule), so a
135
- // never-clicked menu renders and a click opens it, auto-closing a row-mate
136
- // because the shared key can hold only one member name.
137
- actions[menuActionName(rowKey, segName)] = {
138
- set: stateKey,
139
- cycle: [MENU_CLOSED, segName],
140
- };
141
- });
196
+ }
197
+
198
+ // One state var per state key (default "closed"); one cycle action per
199
+ // (stateKey, member). [LAW:dataflow-not-control-flow] Independent menus each
200
+ // contribute their own key; shared-key menus contribute distinct members to one
201
+ // key, and the same-key validator merge unions them into one accordion gate.
202
+ const stateKeys = new Set<string>();
203
+ const actions: Record<string, ActionDecl> = {};
204
+ // Guard against two menus claiming one identity (same key + same member): for
205
+ // independent menus that means the literal same `{{ menu }}` twice in a
206
+ // segment; for shared-key menus it means two menus with the same apply name
207
+ // sharing a key — neither can be addressed distinctly, so reject.
208
+ const claimed = new Set<string>();
209
+ // [LAW:types-are-the-program] The state key is `ident()`-normalized so it carries
210
+ // no separators; that normalization is lossy (`a-b` and `a_b` collapse), so two
211
+ // DISTINCT declarations could map to one key and silently share open-state (an
212
+ // unintended accordion). Track the raw "owner" each key legitimately belongs to
213
+ // — a shared key is owned by its raw key string (every sibling agrees); an
214
+ // independent menu by its raw (segment, apply). A second owner on the same key
215
+ // is a normalization collision, rejected at load so it is unrepresentable
216
+ // [LAW:no-silent-failure] rather than corrupting grouping.
217
+ const ownerByStateKey = new Map<string, string>();
218
+
219
+ for (const [segName, seg] of Object.entries(segments)) {
220
+ if (!segmentReferencesMenu(seg.template)) continue;
221
+ const calls = parseCalls(seg.template);
222
+ if (calls === "parse-failed") continue;
223
+ for (const call of calls) {
224
+ if (call.apply === null) {
225
+ menuIssue(
226
+ ctx,
227
+ `segments.${segName}`,
228
+ `segment "${segName}" has a {{ menu }} whose apply action is not a string literal — a menu's identity is its apply-action name, which must be a literal so it can be gated at load (e.g. {{ menu "applyTheme" "themePage" }}).`,
229
+ );
230
+ continue;
231
+ }
232
+ if (call.key === null) {
233
+ menuIssue(
234
+ ctx,
235
+ `segments.${segName}`,
236
+ `segment "${segName}" has a {{ menu }} whose accordion key is not a string literal — a shared key must be a literal so the mutually-exclusive group can be gated at load (e.g. {{ menu "applyTheme" "themePage" false false "pickers" }}).`,
237
+ );
238
+ continue;
239
+ }
240
+ // [LAW:types-are-the-program] An empty shared key collapses the state key to
241
+ // the bare reserved `menus.` namespace (and a `menus..member` action name).
242
+ // Reject it — a shared key, when present, must name a group.
243
+ if (call.key === "") {
244
+ menuIssue(
245
+ ctx,
246
+ `segments.${segName}`,
247
+ `segment "${segName}" has a {{ menu }} with an empty accordion key — a shared key must be a non-empty name (or omit it for an independent menu).`,
248
+ );
249
+ continue;
250
+ }
251
+ // [LAW:types-are-the-program] An empty apply name → empty member, and the
252
+ // store returns "" for an absent state key, so `open = read === member`
253
+ // would be true before any click — the menu would render open spuriously.
254
+ // Reject it (the member must never alias the absent-state sentinel).
255
+ if (call.apply === "") {
256
+ menuIssue(
257
+ ctx,
258
+ `segments.${segName}`,
259
+ `segment "${segName}" has a {{ menu }} with an empty apply-action name — an empty member aliases the absent-state sentinel ("") so the menu would render open before any click. Name the apply action.`,
260
+ );
261
+ continue;
262
+ }
263
+ const member = menuMember(call.apply);
264
+ // [LAW:types-are-the-program] A member equal to the closed sentinel makes
265
+ // the cycle [closed, "closed"] — two identical members, leaving the menu
266
+ // unopenable. The only apply name that breaks a menu; reject it at load.
267
+ if (member === MENU_CLOSED) {
268
+ menuIssue(
269
+ ctx,
270
+ `segments.${segName}`,
271
+ `segment "${segName}" has a {{ menu }} whose apply action is named "${MENU_CLOSED}", which collides with the menu's closed-state sentinel and leaves it unopenable. Rename the action.`,
272
+ );
273
+ continue;
274
+ }
275
+ const stateKey = menuStateKey(segName, call.apply, call.key);
276
+ // The raw declaration this key legitimately belongs to. Shared-key siblings
277
+ // all share one owner (their raw key); an independent menu owns its key alone
278
+ // (its raw segment+apply, NUL-joined so the two parts can't run together).
279
+ const owner =
280
+ call.key !== undefined
281
+ ? `key${call.key}`
282
+ : `ind${segName}${call.apply}`;
283
+ const priorOwner = ownerByStateKey.get(stateKey);
284
+ if (priorOwner !== undefined && priorOwner !== owner) {
285
+ menuIssue(
286
+ ctx,
287
+ `segments.${segName}`,
288
+ `two {{ menu }} disclosures normalize to the same state key ("${stateKey}") but were declared differently — distinct names that differ only by non-alphanumeric characters (e.g. "a-b" vs "a_b") collapse to one key and would silently share open-state. Rename so they don't collide.`,
289
+ );
290
+ continue;
291
+ }
292
+ ownerByStateKey.set(stateKey, owner);
293
+ const identity = menuActionName(stateKey, member);
294
+ if (claimed.has(identity)) {
295
+ menuIssue(
296
+ ctx,
297
+ `segments.${segName}`,
298
+ `two {{ menu }} disclosures resolve to the same identity ("${identity}") — ${
299
+ call.key !== undefined
300
+ ? `menus sharing key "${call.key}" must have distinct apply actions`
301
+ : `a segment cannot contain two menus over the same apply action "${call.apply}"`
302
+ }.`,
303
+ );
304
+ continue;
305
+ }
306
+ claimed.add(identity);
307
+ stateKeys.add(stateKey);
308
+ // [LAW:one-source-of-truth] Members ordered closed-first: an unset/foreign
309
+ // value counts as the first member (the cycle's "unknown ⇒ first" rule), so
310
+ // a never-clicked menu renders ▸ and a click opens it; a shared key holding
311
+ // one member auto-closes its siblings.
312
+ actions[identity] = { set: stateKey, cycle: [MENU_CLOSED, member] };
313
+ }
314
+ }
142
315
 
143
316
  if (stateKeys.size === 0) return;
144
317
 
145
318
  const variables: Record<string, VariableDecl> = {};
146
- for (const stateKey of stateKeys.keys()) {
319
+ for (const stateKey of stateKeys) {
147
320
  variables[stateKey] = {
148
321
  kind: "state",
149
322
  key: stateKey,
@@ -54,25 +54,28 @@ export function extractActionRefs(template: string): Set<string> {
54
54
  return refs;
55
55
  }
56
56
 
57
- // [LAW:dataflow-not-control-flow] Extract the action names a `picker` call
58
- // references — its FIRST TWO string-literal args are the apply action and the
59
- // page action (`{{ picker "applyTheme" "themePage" true true }}`). Same code/
60
- // string-span walk as extractActionRefs; the picker keyword arms the next literal
61
- // as the apply name and the one after it as the page name (the trailing bool args
62
- // are not string literals, so they are never captured).
63
- const PICKER_ARG_RE = /\bpicker\s+$/;
64
- export function extractPickerRefs(template: string): Set<string> {
57
+ // [LAW:dataflow-not-control-flow] Extract the action names a `picker` OR `menu`
58
+ // call references — both bind the SAME (apply, page) action pair as their FIRST
59
+ // TWO string-literal args (`{{ picker "applyTheme" "themePage" true true }}`,
60
+ // `{{ menu "applyTheme" "themePage" false true "key" }}`). A menu's body IS a
61
+ // picker, so the existence check on those two refs is identical; one extractor
62
+ // arms on either keyword [LAW:single-enforcer]. Same code/string-span walk as
63
+ // extractActionRefs; the keyword arms the next literal as the apply name and the
64
+ // one after as the page name (trailing bools/the menu key are captured only if
65
+ // they fall in the first two literal slots, which they never do).
66
+ const PICKER_OR_MENU_ARG_RE = /\b(?:picker|menu)\s+$/;
67
+ export function extractPickerMenuRefs(template: string): Set<string> {
65
68
  const refs = new Set<string>();
66
69
  TEMPLATE_BLOCK_RE.lastIndex = 0;
67
70
  let m: RegExpExecArray | null;
68
71
  while ((m = TEMPLATE_BLOCK_RE.exec(template)) !== null) {
69
72
  const block = m[1]!;
70
73
  let cursor = 0;
71
- let pending = 0; // remaining name args to capture for the current picker call
74
+ let pending = 0; // remaining name args to capture for the current call
72
75
  let s: RegExpExecArray | null;
73
76
  STRING_LITERAL_RE.lastIndex = 0;
74
77
  while ((s = STRING_LITERAL_RE.exec(block)) !== null) {
75
- if (PICKER_ARG_RE.test(block.slice(cursor, s.index))) pending = 2;
78
+ if (PICKER_OR_MENU_ARG_RE.test(block.slice(cursor, s.index))) pending = 2;
76
79
  if (pending > 0) {
77
80
  refs.add(s[0].slice(1, -1));
78
81
  pending--;