@wcstack/state 1.27.0 → 1.29.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.
@@ -0,0 +1,1527 @@
1
+ const DELIMITER = '.';
2
+ const WILDCARD = '*';
3
+ const MAX_WILDCARD_DEPTH = 128;
4
+ // data-wcs バインディング構文 `[prop][#mod]: [path][@state][|filter...]` の区切り文字(単一正本)。
5
+ // これらは「死守の壁(構文契約)」であり値は不変。manifest.syntax.delimiters で公開される。
6
+ const BINDING_SEPARATOR = ';'; // 複数バインディングの区切り
7
+ const PROP_VALUE_SEPARATOR = ':'; // 左辺(prop)と右辺(path)の区切り
8
+ const MODIFIER_SEPARATOR = '#'; // prop と修飾子の区切り
9
+ const STATE_NAME_SEPARATOR = '@'; // path と @stateName の区切り
10
+ const FILTER_SEPARATOR = '|'; // フィルタパイプの区切り
11
+ // bindingType 判別と左辺 namespace の語彙(単一正本)。manifest.syntax.bindingTypes で
12
+ // 公開される。パーサ(parseBindTextsForElement)とイベント層はこの定数に分岐する。
13
+ // apply 層のディスパッチマップ(apply/applyChange.ts の applyChangeByFirstSegment)の
14
+ // キー集合との一致は __tests__/manifest.test.ts の drift テストが強制する —
15
+ // manifest エントリ(DOM 非依存)から apply 層を import しないための分離。
16
+ const ELSE_KEYWORD = 'else';
17
+ const SPREAD_PROP = '...';
18
+ const EVENT_PROP_PREFIX = 'on';
19
+ const EVENT_TOKEN_NAMESPACE = 'eventToken';
20
+ // リストインデックス参照名(`$1`..`$N`)の接頭辞(単一正本)。
21
+ // manifest.syntax.indexParam で公開される。
22
+ const INDEX_PARAM_PREFIX = '$';
23
+ /**
24
+ * stackIndexByIndexName
25
+ * インデックス名からスタックインデックスへのマッピング
26
+ * $1 => 0
27
+ * $2 => 1
28
+ * :
29
+ * ${i + 1} => i
30
+ * i < MAX_WILDCARD_DEPTH
31
+ */
32
+ const tmpIndexByIndexName = {};
33
+ for (let i = 0; i < MAX_WILDCARD_DEPTH; i++) {
34
+ tmpIndexByIndexName[`${INDEX_PARAM_PREFIX}${i + 1}`] = i;
35
+ }
36
+ Object.freeze(tmpIndexByIndexName);
37
+
38
+ const _cache = new Map();
39
+ /**
40
+ * **tooling 専用**(`@wcstack/state/parser` の clearParserCaches からのみ呼ぶ)。
41
+ * ランタイム文脈で呼んではならない — PathInfo のインスタンス同一性は正規化キー
42
+ * (依存グラフ・アドレス比較)の前提であり、クリアすると同一パスの新旧インスタンスが
43
+ * 併存して identity 比較が黙って壊れる。言語サーバー等の長時間プロセスが、編集中の
44
+ * 中間パス(`user.n` 等)の恒久 intern によるメモリ単調増加を断つための出口。
45
+ */
46
+ function clearPathInfoCacheForTooling() {
47
+ _cache.clear();
48
+ }
49
+ let id = 0;
50
+ function getPathInfo(path) {
51
+ let pathInfo = _cache.get(path);
52
+ if (typeof pathInfo !== "undefined") {
53
+ return pathInfo;
54
+ }
55
+ pathInfo = Object.freeze(new PathInfo(path));
56
+ _cache.set(path, pathInfo);
57
+ return pathInfo;
58
+ }
59
+ class PathInfo {
60
+ id = ++id;
61
+ path;
62
+ segments;
63
+ lastSegment;
64
+ cumulativePaths;
65
+ cumulativePathSet;
66
+ cumulativePathInfos;
67
+ cumulativePathInfoSet;
68
+ parentPath;
69
+ wildcardPaths;
70
+ wildcardPathSet;
71
+ indexByWildcardPath;
72
+ wildcardPathInfos;
73
+ wildcardPathInfoSet;
74
+ wildcardParentPaths;
75
+ wildcardParentPathSet;
76
+ wildcardParentPathInfos;
77
+ wildcardParentPathInfoSet;
78
+ wildcardPositions;
79
+ lastWildcardPath;
80
+ lastWildcardInfo;
81
+ wildcardCount;
82
+ parentPathInfo;
83
+ constructor(path) {
84
+ // Helper to get or create StructuredPathInfo instances, avoiding redundant creation for self-reference
85
+ const getPattern = (_path) => {
86
+ return (path === _path) ? this : getPathInfo(_path);
87
+ };
88
+ // Split the pattern into individual path segments (e.g., "items.*.name" → ["items", "*", "name"])
89
+ const segments = path.split(".");
90
+ // Arrays to track all cumulative paths from root to each segment
91
+ const cumulativePaths = [];
92
+ const cumulativePathInfos = [];
93
+ // Arrays to track wildcard-specific information
94
+ const wildcardPaths = [];
95
+ const indexByWildcardPath = {}; // Maps wildcard path to its index position
96
+ const wildcardPathInfos = [];
97
+ const wildcardParentPaths = []; // Paths of parent segments for each wildcard
98
+ const wildcardParentPathInfos = [];
99
+ const wildcardPositions = [];
100
+ let currentPatternPath = "", prevPatternPath = "";
101
+ let wildcardCount = 0;
102
+ // Iterate through each segment to build cumulative paths and identify wildcards
103
+ for (let i = 0; i < segments.length; i++) {
104
+ currentPatternPath += segments[i];
105
+ // If this segment is a wildcard, track it with all wildcard-specific metadata
106
+ if (segments[i] === WILDCARD) {
107
+ wildcardPaths.push(currentPatternPath);
108
+ indexByWildcardPath[currentPatternPath] = wildcardCount; // Store wildcard's ordinal position
109
+ wildcardPathInfos.push(getPattern(currentPatternPath));
110
+ wildcardParentPaths.push(prevPatternPath); // Parent path is the previous cumulative path
111
+ wildcardParentPathInfos.push(getPattern(prevPatternPath));
112
+ wildcardPositions.push(i);
113
+ wildcardCount++;
114
+ }
115
+ // Track all cumulative paths for hierarchical navigation (e.g., "items", "items.*", "items.*.name")
116
+ cumulativePaths.push(currentPatternPath);
117
+ cumulativePathInfos.push(getPattern(currentPatternPath));
118
+ // Save current path as previous for next iteration, then add separator
119
+ prevPatternPath = currentPatternPath;
120
+ currentPatternPath += ".";
121
+ }
122
+ // Determine the deepest (last) wildcard path and the parent path of the entire pattern
123
+ const lastWildcardPath = wildcardPaths.length > 0 ? wildcardPaths[wildcardPaths.length - 1] : null;
124
+ const parentPath = cumulativePaths.length > 1 ? cumulativePaths[cumulativePaths.length - 2] : null;
125
+ // Assign all analyzed data to readonly properties
126
+ this.path = path;
127
+ this.segments = segments;
128
+ this.lastSegment = segments[segments.length - 1];
129
+ this.cumulativePaths = cumulativePaths;
130
+ this.cumulativePathSet = new Set(cumulativePaths); // Set for fast lookup
131
+ this.cumulativePathInfos = cumulativePathInfos;
132
+ this.cumulativePathInfoSet = new Set(cumulativePathInfos);
133
+ this.wildcardPaths = wildcardPaths;
134
+ this.wildcardPathSet = new Set(wildcardPaths);
135
+ this.indexByWildcardPath = indexByWildcardPath;
136
+ this.wildcardPathInfos = wildcardPathInfos;
137
+ this.wildcardPathInfoSet = new Set(wildcardPathInfos);
138
+ this.wildcardParentPaths = wildcardParentPaths;
139
+ this.wildcardParentPathSet = new Set(wildcardParentPaths);
140
+ this.wildcardParentPathInfos = wildcardParentPathInfos;
141
+ this.wildcardParentPathInfoSet = new Set(wildcardParentPathInfos);
142
+ this.wildcardPositions = wildcardPositions;
143
+ this.lastWildcardPath = lastWildcardPath;
144
+ this.lastWildcardInfo = lastWildcardPath ? getPattern(lastWildcardPath) : null;
145
+ this.parentPath = parentPath;
146
+ this.parentPathInfo = parentPath ? getPattern(parentPath) : null;
147
+ this.wildcardCount = wildcardCount;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * errorGuidance.ts — エラーメッセージへの self-fix 誘導(GTM 2-5 /
153
+ * docs/static-wiring-dx-design.md §3)。
154
+ *
155
+ * コンソールは「書き手(人間・AI とも)が誤った瞬間に必ず読む面」なので、
156
+ * (a) did-you-mean 候補 (b) lint への誘導 をエラーメッセージ自体に埋め込む。
157
+ * ここの関数は全て**エラーパスでのみ**呼ばれる — 正常系のコストはゼロ。
158
+ * auto.min.js に同梱されるため文字列は最小限に保つ(エラーパス専用モジュールの
159
+ * 遅延 import は `src/auto.ts` の SRI 自己完結制約で不可)。
160
+ *
161
+ * 診断 code の語彙はコンソール → lint → IDE の三面で共有する:
162
+ * メッセージ先頭の `[wcs/...]` は wcstack-intellisense / @wcstack/lint の
163
+ * 安定診断 code(packages/vscode-wcs/src/core/diagnostics.ts)と同一。
164
+ */
165
+ /** 挿入・削除・置換の編集距離。長さ差が max を超えたら早期に max+1 を返す。 */
166
+ function editDistance(a, b, max) {
167
+ if (Math.abs(a.length - b.length) > max) {
168
+ return max + 1;
169
+ }
170
+ const prev = new Array(b.length + 1);
171
+ const curr = new Array(b.length + 1);
172
+ for (let j = 0; j <= b.length; j++) {
173
+ prev[j] = j;
174
+ }
175
+ for (let i = 1; i <= a.length; i++) {
176
+ curr[0] = i;
177
+ for (let j = 1; j <= b.length; j++) {
178
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
179
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
180
+ }
181
+ for (let j = 0; j <= b.length; j++) {
182
+ prev[j] = curr[j];
183
+ }
184
+ }
185
+ return prev[b.length];
186
+ }
187
+ /**
188
+ * 候補集合から編集距離 2 以内の最近傍を探し、` Did you mean "<best>"?` を返す。
189
+ * 該当なしは空文字。規準(距離 2・同距離は先勝ち・大小文字は畳んで比較)は
190
+ * lint の did-you-mean(ioNodeValidator の suggestion)と同じ — 三面で提案が
191
+ * 割れないように揃えている。動的キー等で候補が列挙できないサイトでは呼ばない
192
+ * = 誘導文のみに縮退(設計 §3 の縮退)。
193
+ */
194
+ function didYouMean(input, candidates) {
195
+ // 空入力(`a|` の末尾パイプ等)に短い候補を提案しても無意味なので出さない。
196
+ if (input.length === 0) {
197
+ return "";
198
+ }
199
+ const folded = input.toLowerCase();
200
+ let best = null;
201
+ let bestDistance = 3;
202
+ for (const candidate of candidates) {
203
+ const distance = editDistance(folded, candidate.toLowerCase(), 2);
204
+ if (distance < bestDistance) {
205
+ best = candidate;
206
+ bestDistance = distance;
207
+ }
208
+ }
209
+ return best !== null ? ` Did you mean "${best}"?` : "";
210
+ }
211
+ /**
212
+ * lint への誘導(誘導付きメッセージ共通の一文)。
213
+ * **lint が実際にそのケースを検出するサイトにだけ付ける** — 検出しないケースに
214
+ * 付けると「エラー → lint 実行 → clean」の空振りで検証ループの信頼を毀損する。
215
+ * 現在 lint 未検出のため付けないもの: DCC 宣言・watch の空キー / Object.prototype
216
+ * 継承名 / ワイルドカード深度超過。
217
+ * なお hint 付きサイト内でも被覆は部分的でありうる(例: `$watch: ident` の実体が
218
+ * 非オブジェクトだった場合、ランタイムは評価後の値で raise するが lint は宣言 shape
219
+ * から断定できず沈黙する)。サイト粒度の hint ではこの残余は構造的に避けられない。
220
+ */
221
+ const LINT_HINT = " Validate statically: npx @wcstack/lint <file>.";
222
+
223
+ function raiseError(message) {
224
+ throw new Error(`[@wcstack/state] ${message}`);
225
+ }
226
+
227
+ const STRUCTURAL_BINDING_TYPE_SET = new Set([
228
+ "if",
229
+ "elseif",
230
+ "else",
231
+ "for",
232
+ ]);
233
+
234
+ const _config = {
235
+ locale: 'en'};
236
+ // backward compatible export (read-only usage)
237
+ const config = _config;
238
+
239
+ /**
240
+ * errorMessages.ts
241
+ *
242
+ * Error message generation utilities used by filter functions.
243
+ *
244
+ * Main responsibilities:
245
+ * - Throws clear error messages when filter options or value type checks fail
246
+ * - Takes function name as argument to specify which filter caused the error
247
+ *
248
+ * Design points:
249
+ * - optionsRequired: Error when required option is not specified
250
+ * - optionMustBeNumber: Error when option value is not a number
251
+ * - valueMustBeNumber: Error when value is not a number
252
+ * - valueMustBeBoolean: Error when value is not boolean
253
+ * - valueMustBeDate: Error when value is not a Date
254
+ */
255
+ /**
256
+ * Throws error when filter requires at least one option but none provided.
257
+ *
258
+ * @param fnName - Name of the filter function
259
+ * @returns Never returns (always throws)
260
+ */
261
+ function optionsRequired(fnName) {
262
+ raiseError(`filter ${fnName} requires at least one option`);
263
+ }
264
+ /**
265
+ * Throws error when filter option must be a number but invalid value provided.
266
+ *
267
+ * @param fnName - Name of the filter function
268
+ * @returns Never returns (always throws)
269
+ */
270
+ function optionMustBeNumber(fnName) {
271
+ raiseError(`filter ${fnName} requires a number as option`);
272
+ }
273
+ /**
274
+ * Throws error when filter requires numeric value but non-number provided.
275
+ *
276
+ * @param fnName - Name of the filter function
277
+ * @returns Never returns (always throws)
278
+ */
279
+ function valueMustBeNumber(fnName) {
280
+ raiseError(`filter ${fnName} requires a number value`);
281
+ }
282
+ /**
283
+ * Throws error when filter requires boolean value but non-boolean provided.
284
+ *
285
+ * @param fnName - Name of the filter function
286
+ * @returns Never returns (always throws)
287
+ */
288
+ function valueMustBeBoolean(fnName) {
289
+ raiseError(`filter ${fnName} requires a boolean value`);
290
+ }
291
+ /**
292
+ * Throws error when filter requires Date value but non-Date provided.
293
+ *
294
+ * @param fnName - Name of the filter function
295
+ * @returns Never returns (always throws)
296
+ */
297
+ function valueMustBeDate(fnName) {
298
+ raiseError(`filter ${fnName} requires a date value`);
299
+ }
300
+ /**
301
+ * Throws error when filter requires array value but non-array provided.
302
+ *
303
+ * @param fnName - Name of the filter function
304
+ * @returns Never returns (always throws)
305
+ */
306
+ function valueMustBeArray(fnName) {
307
+ raiseError(`filter ${fnName} requires an array value`);
308
+ }
309
+
310
+ /**
311
+ * builtinFilters.ts
312
+ *
313
+ * Implementation file for built-in filter functions available in Structive.
314
+ *
315
+ * Main responsibilities:
316
+ * - Provides filters for conversion, comparison, formatting, and validation of numbers, strings, dates, booleans, etc.
317
+ * - Defines functions with options for each filter name, enabling flexible use during binding
318
+ * - Designed for common use as both input and output filters
319
+ *
320
+ * Design points:
321
+ * - Comprehensive coverage of diverse filters: eq, ne, lt, gt, inc, abs, clamp, fix, locale, uc, lc, cap, trim, slice, pad, truncate, join, int, float, round, percent, unit, date, time, ymd, hms, falsy, truthy, defaults, boolean, number, string, null, etc.
322
+ * - Rich type checking and error handling for option values
323
+ * - Centralized management of filter functions with FilterWithOptions type, easy to extend
324
+ * - Dynamic retrieval of filter functions from filter names and options via builtinFilterFn
325
+ */
326
+ function validateNumberString(value) {
327
+ if (!value || isNaN(Number(value))) {
328
+ return false;
329
+ }
330
+ return true;
331
+ }
332
+ /**
333
+ * Equality filter - compares value with option.
334
+ *
335
+ * @param options - Array with comparison value as first element
336
+ * @returns Filter function that returns boolean
337
+ */
338
+ const eq = (options) => {
339
+ const opt = options?.[0] ?? optionsRequired('eq');
340
+ return (value) => {
341
+ // Align types for comparison
342
+ if (typeof value === 'number') {
343
+ if (!validateNumberString(opt)) {
344
+ optionMustBeNumber('eq');
345
+ }
346
+ return value === Number(opt);
347
+ }
348
+ if (typeof value === 'string') {
349
+ return value === opt;
350
+ }
351
+ // Strict equality for others
352
+ return value === opt;
353
+ };
354
+ };
355
+ /**
356
+ * Inequality filter - compares value with option.
357
+ *
358
+ * @param options - Array with comparison value as first element
359
+ * @returns Filter function that returns boolean
360
+ */
361
+ const ne = (options) => {
362
+ const opt = options?.[0] ?? optionsRequired('ne');
363
+ return (value) => {
364
+ // Align types for comparison
365
+ if (typeof value === 'number') {
366
+ if (!validateNumberString(opt)) {
367
+ optionMustBeNumber('ne');
368
+ }
369
+ return value !== Number(opt);
370
+ }
371
+ if (typeof value === 'string') {
372
+ return value !== opt;
373
+ }
374
+ // Strict equality for others
375
+ return value !== opt;
376
+ };
377
+ };
378
+ /**
379
+ * Boolean NOT filter - inverts boolean value.
380
+ *
381
+ * @param options - Unused
382
+ * @returns Filter function that returns inverted boolean
383
+ */
384
+ const not = (_options) => {
385
+ return (value) => {
386
+ if (typeof value !== 'boolean') {
387
+ valueMustBeBoolean('not');
388
+ }
389
+ return !value;
390
+ };
391
+ };
392
+ /**
393
+ * Less than filter - checks if value is less than option.
394
+ *
395
+ * @param options - Array with comparison number as first element
396
+ * @returns Filter function that returns boolean
397
+ */
398
+ const lt = (options) => {
399
+ const opt = options?.[0] ?? optionsRequired('lt');
400
+ if (!validateNumberString(opt)) {
401
+ optionMustBeNumber('lt');
402
+ }
403
+ return (value) => {
404
+ if (typeof value !== 'number') {
405
+ valueMustBeNumber('lt');
406
+ }
407
+ return value < Number(opt);
408
+ };
409
+ };
410
+ /**
411
+ * Less than or equal filter - checks if value is less than or equal to option.
412
+ *
413
+ * @param options - Array with comparison number as first element
414
+ * @returns Filter function that returns boolean
415
+ */
416
+ const le = (options) => {
417
+ const opt = options?.[0] ?? optionsRequired('le');
418
+ if (!validateNumberString(opt)) {
419
+ optionMustBeNumber('le');
420
+ }
421
+ return (value) => {
422
+ if (typeof value !== 'number') {
423
+ valueMustBeNumber('le');
424
+ }
425
+ return value <= Number(opt);
426
+ };
427
+ };
428
+ /**
429
+ * Greater than filter - checks if value is greater than option.
430
+ *
431
+ * @param options - Array with comparison number as first element
432
+ * @returns Filter function that returns boolean
433
+ */
434
+ const gt = (options) => {
435
+ const opt = options?.[0] ?? optionsRequired('gt');
436
+ if (!validateNumberString(opt)) {
437
+ optionMustBeNumber('gt');
438
+ }
439
+ return (value) => {
440
+ if (typeof value !== 'number') {
441
+ valueMustBeNumber('gt');
442
+ }
443
+ return value > Number(opt);
444
+ };
445
+ };
446
+ /**
447
+ * Greater than or equal filter - checks if value is greater than or equal to option.
448
+ *
449
+ * @param options - Array with comparison number as first element
450
+ * @returns Filter function that returns boolean
451
+ */
452
+ const ge = (options) => {
453
+ const opt = options?.[0] ?? optionsRequired('ge');
454
+ if (!validateNumberString(opt)) {
455
+ optionMustBeNumber('ge');
456
+ }
457
+ return (value) => {
458
+ if (typeof value !== 'number') {
459
+ valueMustBeNumber('ge');
460
+ }
461
+ return value >= Number(opt);
462
+ };
463
+ };
464
+ /**
465
+ * Increment filter - adds option value to input value.
466
+ *
467
+ * @param options - Array with increment number as first element
468
+ * @returns Filter function that returns incremented number
469
+ */
470
+ const inc = (options) => {
471
+ const opt = options?.[0] ?? optionsRequired('inc');
472
+ if (!validateNumberString(opt)) {
473
+ optionMustBeNumber('inc');
474
+ }
475
+ return (value) => {
476
+ if (typeof value !== 'number') {
477
+ valueMustBeNumber('inc');
478
+ }
479
+ return value + Number(opt);
480
+ };
481
+ };
482
+ /**
483
+ * Decrement filter - subtracts option value from input value.
484
+ *
485
+ * @param options - Array with decrement number as first element
486
+ * @returns Filter function that returns decremented number
487
+ */
488
+ const dec = (options) => {
489
+ const opt = options?.[0] ?? optionsRequired('dec');
490
+ if (!validateNumberString(opt)) {
491
+ optionMustBeNumber('dec');
492
+ }
493
+ return (value) => {
494
+ if (typeof value !== 'number') {
495
+ valueMustBeNumber('dec');
496
+ }
497
+ return value - Number(opt);
498
+ };
499
+ };
500
+ /**
501
+ * Multiply filter - multiplies value by option.
502
+ *
503
+ * @param options - Array with multiplier number as first element
504
+ * @returns Filter function that returns multiplied number
505
+ */
506
+ const mul = (options) => {
507
+ const opt = options?.[0] ?? optionsRequired('mul');
508
+ if (!validateNumberString(opt)) {
509
+ optionMustBeNumber('mul');
510
+ }
511
+ return (value) => {
512
+ if (typeof value !== 'number') {
513
+ valueMustBeNumber('mul');
514
+ }
515
+ return value * Number(opt);
516
+ };
517
+ };
518
+ /**
519
+ * Divide filter - divides value by option.
520
+ *
521
+ * @param options - Array with divisor number as first element
522
+ * @returns Filter function that returns divided number
523
+ */
524
+ const div = (options) => {
525
+ const opt = options?.[0] ?? optionsRequired('div');
526
+ if (!validateNumberString(opt)) {
527
+ optionMustBeNumber('div');
528
+ }
529
+ return (value) => {
530
+ if (typeof value !== 'number') {
531
+ valueMustBeNumber('div');
532
+ }
533
+ return value / Number(opt);
534
+ };
535
+ };
536
+ /**
537
+ * Modulo filter - returns remainder of division.
538
+ *
539
+ * @param options - Array with divisor number as first element
540
+ * @returns Filter function that returns remainder
541
+ */
542
+ const mod = (options) => {
543
+ const opt = options?.[0] ?? optionsRequired('mod');
544
+ if (!validateNumberString(opt)) {
545
+ optionMustBeNumber('mod');
546
+ }
547
+ return (value) => {
548
+ if (typeof value !== 'number') {
549
+ valueMustBeNumber('mod');
550
+ }
551
+ return value % Number(opt);
552
+ };
553
+ };
554
+ /**
555
+ * Absolute value filter - returns the magnitude of a number.
556
+ *
557
+ * @param options - Unused
558
+ * @returns Filter function that returns the absolute value
559
+ */
560
+ const abs = (_options) => {
561
+ return (value) => {
562
+ if (typeof value !== 'number') {
563
+ valueMustBeNumber('abs');
564
+ }
565
+ return Math.abs(value);
566
+ };
567
+ };
568
+ /**
569
+ * Clamp filter - constrains a number to the inclusive range [min, max].
570
+ *
571
+ * Saturating conversion in the same family as round/floor/ceil, so it stays on
572
+ * the wire rather than in state. Pairs with `unit` for style bindings:
573
+ * `style.width: ratio|clamp(0,1)|percent(0)`.
574
+ *
575
+ * @param options - Array with minimum as first element and maximum as second (both required)
576
+ * @returns Filter function that returns the clamped number
577
+ */
578
+ const clamp = (options) => {
579
+ const opt1 = options?.[0] ?? optionsRequired('clamp');
580
+ if (!validateNumberString(opt1)) {
581
+ optionMustBeNumber('clamp');
582
+ }
583
+ const opt2 = options?.[1] ?? optionsRequired('clamp');
584
+ if (!validateNumberString(opt2)) {
585
+ optionMustBeNumber('clamp');
586
+ }
587
+ const min = Number(opt1);
588
+ const max = Number(opt2);
589
+ return (value) => {
590
+ if (typeof value !== 'number') {
591
+ valueMustBeNumber('clamp');
592
+ }
593
+ return Math.min(Math.max(value, min), max);
594
+ };
595
+ };
596
+ /**
597
+ * Fixed decimal filter - formats number to fixed decimal places.
598
+ *
599
+ * @param options - Array with decimal places as first element (default: 0)
600
+ * @returns Filter function that returns formatted string
601
+ */
602
+ const fix = (options) => {
603
+ const opt = options?.[0] ?? "0";
604
+ if (!validateNumberString(opt)) {
605
+ optionMustBeNumber('fix');
606
+ }
607
+ return (value) => {
608
+ if (typeof value !== 'number') {
609
+ valueMustBeNumber('fix');
610
+ }
611
+ return value.toFixed(Number(opt));
612
+ };
613
+ };
614
+ /**
615
+ * Locale number filter - formats number according to locale.
616
+ *
617
+ * @param options - Array with locale string as first element (default: config.locale)
618
+ * @returns Filter function that returns localized number string
619
+ */
620
+ const locale = (options) => {
621
+ const opt = options?.[0] ?? config.locale;
622
+ return (value) => {
623
+ if (typeof value !== 'number') {
624
+ valueMustBeNumber('locale');
625
+ }
626
+ return value.toLocaleString(opt);
627
+ };
628
+ };
629
+ /**
630
+ * Uppercase filter - converts string to uppercase.
631
+ *
632
+ * @param options - Unused
633
+ * @returns Filter function that returns uppercase string
634
+ */
635
+ const uc = (_options) => {
636
+ return (value) => {
637
+ return String(value).toUpperCase();
638
+ };
639
+ };
640
+ /**
641
+ * Lowercase filter - converts string to lowercase.
642
+ *
643
+ * @param options - Unused
644
+ * @returns Filter function that returns lowercase string
645
+ */
646
+ const lc = (_options) => {
647
+ return (value) => {
648
+ return String(value).toLowerCase();
649
+ };
650
+ };
651
+ /**
652
+ * Capitalize filter - capitalizes first character of string.
653
+ *
654
+ * @param options - Unused
655
+ * @returns Filter function that returns capitalized string
656
+ */
657
+ const cap = (_options) => {
658
+ return (value) => {
659
+ const v = String(value);
660
+ if (v.length === 0) {
661
+ return v;
662
+ }
663
+ if (v.length === 1) {
664
+ return v.toUpperCase();
665
+ }
666
+ return v.charAt(0).toUpperCase() + v.slice(1);
667
+ };
668
+ };
669
+ /**
670
+ * Trim filter - removes whitespace from both ends of string.
671
+ *
672
+ * @param options - Unused
673
+ * @returns Filter function that returns trimmed string
674
+ */
675
+ const trim = (_options) => {
676
+ return (value) => {
677
+ return String(value).trim();
678
+ };
679
+ };
680
+ /**
681
+ * Slice filter - extracts portion of string from specified index.
682
+ *
683
+ * @param options - Array with start index and optional end index
684
+ * @returns Filter function that returns sliced string
685
+ */
686
+ const slice = (options) => {
687
+ const numberedOpts = [];
688
+ const opt1 = options?.[0] ?? optionsRequired('slice');
689
+ if (!validateNumberString(opt1)) {
690
+ optionMustBeNumber('slice');
691
+ }
692
+ numberedOpts.push(Number(opt1));
693
+ const opt2 = options?.[1];
694
+ if (typeof opt2 !== 'undefined') {
695
+ if (!validateNumberString(opt2)) {
696
+ optionMustBeNumber('slice');
697
+ }
698
+ numberedOpts.push(Number(opt2));
699
+ }
700
+ return (value) => {
701
+ return String(value).slice(...numberedOpts);
702
+ };
703
+ };
704
+ /**
705
+ * Substring filter - extracts substring from specified position and length.
706
+ *
707
+ * @param options - Array with start index and length
708
+ * @returns Filter function that returns substring
709
+ */
710
+ const substr = (options) => {
711
+ const opt1 = options?.[0] ?? optionsRequired('substr');
712
+ if (!validateNumberString(opt1)) {
713
+ optionMustBeNumber('substr');
714
+ }
715
+ const opt2 = options?.[1] ?? optionsRequired('substr');
716
+ if (!validateNumberString(opt2)) {
717
+ optionMustBeNumber('substr');
718
+ }
719
+ return (value) => {
720
+ return String(value).substr(Number(opt1), Number(opt2));
721
+ };
722
+ };
723
+ /**
724
+ * Pad filter - pads string to specified length from start.
725
+ *
726
+ * @param options - Array with target length and pad string (default: '0')
727
+ * @returns Filter function that returns padded string
728
+ */
729
+ const pad = (options) => {
730
+ const opt1 = options?.[0] ?? optionsRequired('pad');
731
+ if (!validateNumberString(opt1)) {
732
+ optionMustBeNumber('pad');
733
+ }
734
+ const opt2 = options?.[1] ?? '0';
735
+ return (value) => {
736
+ return String(value).padStart(Number(opt1), opt2);
737
+ };
738
+ };
739
+ /**
740
+ * Repeat filter - repeats string specified number of times.
741
+ *
742
+ * @param options - Array with repeat count as first element
743
+ * @returns Filter function that returns repeated string
744
+ */
745
+ const rep = (options) => {
746
+ const opt = options?.[0] ?? optionsRequired('rep');
747
+ if (!validateNumberString(opt)) {
748
+ optionMustBeNumber('rep');
749
+ }
750
+ return (value) => {
751
+ return String(value).repeat(Number(opt));
752
+ };
753
+ };
754
+ /**
755
+ * Reverse filter - reverses character order in string.
756
+ *
757
+ * @param options - Unused
758
+ * @returns Filter function that returns reversed string
759
+ */
760
+ const rev = (_options) => {
761
+ return (value) => {
762
+ return String(value).split('').reverse().join('');
763
+ };
764
+ };
765
+ /**
766
+ * Integer filter - parses value to integer.
767
+ *
768
+ * @param options - Unused
769
+ * @returns Filter function that returns integer
770
+ */
771
+ const int = (_options) => {
772
+ return (value) => {
773
+ return parseInt(String(value), 10);
774
+ };
775
+ };
776
+ /**
777
+ * Float filter - parses value to floating point number.
778
+ *
779
+ * @param options - Unused
780
+ * @returns Filter function that returns float
781
+ */
782
+ const float = (_options) => {
783
+ return (value) => {
784
+ return parseFloat(String(value));
785
+ };
786
+ };
787
+ /**
788
+ * Round filter - rounds number to specified decimal places.
789
+ *
790
+ * @param options - Array with decimal places as first element (default: 0)
791
+ * @returns Filter function that returns rounded number
792
+ */
793
+ const round = (options) => {
794
+ const opt = options?.[0] ?? '0';
795
+ if (!validateNumberString(opt)) {
796
+ optionMustBeNumber('round');
797
+ }
798
+ return (value) => {
799
+ if (typeof value !== 'number') {
800
+ valueMustBeNumber('round');
801
+ }
802
+ const optValue = Math.pow(10, Number(opt));
803
+ return Math.round(value * optValue) / optValue;
804
+ };
805
+ };
806
+ /**
807
+ * Floor filter - rounds number down to specified decimal places.
808
+ *
809
+ * @param options - Array with decimal places as first element (default: 0)
810
+ * @returns Filter function that returns floored number
811
+ */
812
+ const floor = (options) => {
813
+ const opt = options?.[0] ?? '0';
814
+ if (!validateNumberString(opt)) {
815
+ optionMustBeNumber('floor');
816
+ }
817
+ return (value) => {
818
+ if (typeof value !== 'number') {
819
+ valueMustBeNumber('floor');
820
+ }
821
+ const optValue = Math.pow(10, Number(opt));
822
+ return Math.floor(value * optValue) / optValue;
823
+ };
824
+ };
825
+ /**
826
+ * Ceiling filter - rounds number up to specified decimal places.
827
+ *
828
+ * @param options - Array with decimal places as first element (default: 0)
829
+ * @returns Filter function that returns ceiled number
830
+ */
831
+ const ceil = (options) => {
832
+ const opt = options?.[0] ?? '0';
833
+ if (!validateNumberString(opt)) {
834
+ optionMustBeNumber('ceil');
835
+ }
836
+ return (value) => {
837
+ if (typeof value !== 'number') {
838
+ valueMustBeNumber('ceil');
839
+ }
840
+ const optValue = Math.pow(10, Number(opt));
841
+ return Math.ceil(value * optValue) / optValue;
842
+ };
843
+ };
844
+ /**
845
+ * Percent filter - formats number as percentage string.
846
+ *
847
+ * @param options - Array with decimal places as first element (default: 0)
848
+ * @returns Filter function that returns percentage string with '%'
849
+ */
850
+ const percent = (options) => {
851
+ const opt = options?.[0] ?? '0';
852
+ if (!validateNumberString(opt)) {
853
+ optionMustBeNumber('percent');
854
+ }
855
+ return (value) => {
856
+ if (typeof value !== 'number') {
857
+ valueMustBeNumber('percent');
858
+ }
859
+ return `${(value * 100).toFixed(Number(opt))}%`;
860
+ };
861
+ };
862
+ /**
863
+ * Unit filter - appends a CSS unit (or any suffix) to the value.
864
+ *
865
+ * A number alone does nothing in CSS, so without this the unit has to be built in
866
+ * state — which drags presentation into the source of truth, and in the worst case
867
+ * forces a whole derived array just to carry `"42%"` strings.
868
+ * `style.height: samples.*.cpu|clamp(0,100)|fix(0)|unit(%)` keeps it on the wire.
869
+ *
870
+ * Accepts strings as well as numbers **on purpose**: the useful chains run through
871
+ * `fix` / `percent`, which already return strings. Rejecting non-numbers here would
872
+ * break exactly the combination this filter exists for.
873
+ *
874
+ * `null` / `undefined` pass through untouched rather than becoming `"undefinedpx"`,
875
+ * so the binding layer's "undefined skips the write, null clears" semantics survive.
876
+ *
877
+ * @param options - Array with the unit/suffix as first element (required)
878
+ * @returns Filter function that returns the value with the unit appended
879
+ */
880
+ const unit = (options) => {
881
+ const opt = options?.[0] ?? optionsRequired('unit');
882
+ return (value) => {
883
+ if (value === null || typeof value === 'undefined') {
884
+ return value;
885
+ }
886
+ return String(value) + opt;
887
+ };
888
+ };
889
+ /**
890
+ * Join filter - joins array elements into a string.
891
+ *
892
+ * The default separator is `", "` rather than `","`: a bare comma is what `String()`
893
+ * already produces without any filter, so defaulting to it would make `|join` a no-op.
894
+ *
895
+ * @param options - Array with separator as first element (default: ', ')
896
+ * @returns Filter function that returns the joined string
897
+ */
898
+ const join = (options) => {
899
+ const opt = options?.[0] ?? ', ';
900
+ return (value) => {
901
+ if (!Array.isArray(value)) {
902
+ valueMustBeArray('join');
903
+ }
904
+ return value.join(opt);
905
+ };
906
+ };
907
+ /**
908
+ * Truncate filter - shortens a string and appends an ellipsis.
909
+ *
910
+ * The length option counts **kept characters**, not the total including the suffix,
911
+ * matching the existing `slice(0, n)` reading. A string at or below the limit is
912
+ * returned untouched (no suffix).
913
+ *
914
+ * @param options - Array with max kept length as first element and suffix as second (default: '…')
915
+ * @returns Filter function that returns the truncated string
916
+ */
917
+ const truncate = (options) => {
918
+ const opt1 = options?.[0] ?? optionsRequired('truncate');
919
+ if (!validateNumberString(opt1)) {
920
+ optionMustBeNumber('truncate');
921
+ }
922
+ const maxLength = Number(opt1);
923
+ const suffix = options?.[1] ?? '…';
924
+ return (value) => {
925
+ const v = String(value);
926
+ if (v.length <= maxLength) {
927
+ return v;
928
+ }
929
+ return v.slice(0, maxLength) + suffix;
930
+ };
931
+ };
932
+ /**
933
+ * Date filter - formats Date object as localized date string.
934
+ *
935
+ * @param options - Array with locale string as first element (default: config.locale)
936
+ * @returns Filter function that returns date string
937
+ */
938
+ const date = (options) => {
939
+ const opt = options?.[0] ?? config.locale;
940
+ return (value) => {
941
+ if (!(value instanceof Date)) {
942
+ valueMustBeDate('date');
943
+ }
944
+ return value.toLocaleDateString(opt);
945
+ };
946
+ };
947
+ /**
948
+ * Time filter - formats Date object as localized time string.
949
+ *
950
+ * @param options - Array with locale string as first element (default: config.locale)
951
+ * @returns Filter function that returns time string
952
+ */
953
+ const time = (options) => {
954
+ const opt = options?.[0] ?? config.locale;
955
+ return (value) => {
956
+ if (!(value instanceof Date)) {
957
+ valueMustBeDate('time');
958
+ }
959
+ return value.toLocaleTimeString(opt);
960
+ };
961
+ };
962
+ /**
963
+ * DateTime filter - formats Date object as localized date and time string.
964
+ *
965
+ * @param options - Array with locale string as first element (default: config.locale)
966
+ * @returns Filter function that returns datetime string
967
+ */
968
+ const datetime = (options) => {
969
+ const opt = options?.[0] ?? config.locale;
970
+ return (value) => {
971
+ if (!(value instanceof Date)) {
972
+ valueMustBeDate('datetime');
973
+ }
974
+ return value.toLocaleString(opt);
975
+ };
976
+ };
977
+ /**
978
+ * Year-Month-Day filter - formats Date object as YYYY-MM-DD string.
979
+ *
980
+ * @param options - Array with separator string as first element (default: '-')
981
+ * @returns Filter function that returns formatted date string
982
+ */
983
+ const ymd = (options) => {
984
+ const opt = options?.[0] ?? '-';
985
+ return (value) => {
986
+ if (!(value instanceof Date)) {
987
+ valueMustBeDate('ymd');
988
+ }
989
+ const year = value.getFullYear().toString();
990
+ const month = (value.getMonth() + 1).toString().padStart(2, '0');
991
+ const day = value.getDate().toString().padStart(2, '0');
992
+ return `${year}${opt}${month}${opt}${day}`;
993
+ };
994
+ };
995
+ /**
996
+ * Hour-Minute-Second filter - formats Date object as HH:MM:SS string.
997
+ *
998
+ * The counterpart of `ymd`: a fixed, zero-padded, locale-independent rendering with a
999
+ * configurable separator, for when `time` (locale-formatted) is not stable enough.
1000
+ *
1001
+ * @param options - Array with separator string as first element (default: ':')
1002
+ * @returns Filter function that returns formatted time string
1003
+ */
1004
+ const hms = (options) => {
1005
+ const opt = options?.[0] ?? ':';
1006
+ return (value) => {
1007
+ if (!(value instanceof Date)) {
1008
+ valueMustBeDate('hms');
1009
+ }
1010
+ const hours = value.getHours().toString().padStart(2, '0');
1011
+ const minutes = value.getMinutes().toString().padStart(2, '0');
1012
+ const seconds = value.getSeconds().toString().padStart(2, '0');
1013
+ return `${hours}${opt}${minutes}${opt}${seconds}`;
1014
+ };
1015
+ };
1016
+ /**
1017
+ * Falsy filter - checks if value is falsy.
1018
+ *
1019
+ * @param options - Unused
1020
+ * @returns Filter function that returns true for false/null/undefined/0/''/NaN
1021
+ */
1022
+ const falsy = (_options) => {
1023
+ return (value) => value === false || value === null || value === undefined || value === 0 || value === '' || Number.isNaN(value);
1024
+ };
1025
+ /**
1026
+ * Truthy filter - checks if value is truthy.
1027
+ *
1028
+ * @param options - Unused
1029
+ * @returns Filter function that returns true for non-falsy values
1030
+ */
1031
+ const truthy = (_options) => {
1032
+ return (value) => value !== false && value !== null && value !== undefined && value !== 0 && value !== '' && !Number.isNaN(value);
1033
+ };
1034
+ /**
1035
+ * Default filter - returns default value if input is falsy.
1036
+ *
1037
+ * @param options - Array with default value as first element
1038
+ * @returns Filter function that returns value or default
1039
+ */
1040
+ const defaults = (options) => {
1041
+ const opt = options?.[0] ?? optionsRequired('defaults');
1042
+ return (value) => {
1043
+ if (value === false || value === null || value === undefined || value === 0 || value === '' || Number.isNaN(value)) {
1044
+ return opt;
1045
+ }
1046
+ return value;
1047
+ };
1048
+ };
1049
+ /**
1050
+ * Boolean filter - converts value to boolean.
1051
+ *
1052
+ * @param options - Unused
1053
+ * @returns Filter function that returns boolean
1054
+ */
1055
+ const boolean = (_options) => {
1056
+ return (value) => {
1057
+ return Boolean(value);
1058
+ };
1059
+ };
1060
+ /**
1061
+ * Number filter - converts value to number.
1062
+ *
1063
+ * @param options - Unused
1064
+ * @returns Filter function that returns number
1065
+ */
1066
+ const number = (_options) => {
1067
+ return (value) => {
1068
+ return Number(value);
1069
+ };
1070
+ };
1071
+ /**
1072
+ * String filter - converts value to string.
1073
+ *
1074
+ * @param options - Unused
1075
+ * @returns Filter function that returns string
1076
+ */
1077
+ const string = (_options) => {
1078
+ return (value) => {
1079
+ return String(value);
1080
+ };
1081
+ };
1082
+ /**
1083
+ * Null filter - converts empty string to null.
1084
+ *
1085
+ * @param options - Unused
1086
+ * @returns Filter function that returns null for empty string, otherwise original value
1087
+ */
1088
+ const _null = (_options) => {
1089
+ return (value) => {
1090
+ return (value === "") ? null : value;
1091
+ };
1092
+ };
1093
+ const builtinFilters = {
1094
+ "eq": eq,
1095
+ "ne": ne,
1096
+ "not": not,
1097
+ "lt": lt,
1098
+ "le": le,
1099
+ "gt": gt,
1100
+ "ge": ge,
1101
+ "inc": inc,
1102
+ "dec": dec,
1103
+ "mul": mul,
1104
+ "div": div,
1105
+ "mod": mod,
1106
+ "abs": abs,
1107
+ "clamp": clamp,
1108
+ "fix": fix,
1109
+ "locale": locale,
1110
+ "uc": uc,
1111
+ "lc": lc,
1112
+ "cap": cap,
1113
+ "trim": trim,
1114
+ "slice": slice,
1115
+ "substr": substr,
1116
+ "pad": pad,
1117
+ "rep": rep,
1118
+ "rev": rev,
1119
+ "truncate": truncate,
1120
+ "join": join,
1121
+ "int": int,
1122
+ "float": float,
1123
+ "round": round,
1124
+ "floor": floor,
1125
+ "ceil": ceil,
1126
+ "percent": percent,
1127
+ "unit": unit,
1128
+ "date": date,
1129
+ "time": time,
1130
+ "datetime": datetime,
1131
+ "ymd": ymd,
1132
+ "hms": hms,
1133
+ "falsy": falsy,
1134
+ "truthy": truthy,
1135
+ "defaults": defaults,
1136
+ "boolean": boolean,
1137
+ "number": number,
1138
+ "string": string,
1139
+ "null": _null,
1140
+ };
1141
+ const outputBuiltinFilters = builtinFilters;
1142
+ const inputBuiltinFilters = builtinFilters;
1143
+ const builtinFiltersByFilterIOType = {
1144
+ "input": inputBuiltinFilters,
1145
+ "output": outputBuiltinFilters,
1146
+ };
1147
+ /**
1148
+ * Retrieves built-in filter function by name and options.
1149
+ *
1150
+ * @param name - Filter name
1151
+ * @param options - Array of option strings
1152
+ * @returns Function that takes FilterWithOptions and returns filter function
1153
+ */
1154
+ const builtinFilterFn = (name, options) => (filters) => {
1155
+ const filter = filters[name];
1156
+ if (!filter) {
1157
+ // lint の wcs/filter-unknown と同じ語彙・同じ did-you-mean 規準(三面同語彙)。
1158
+ raiseError(`[wcs/filter-unknown] filter not found: ${name}.${didYouMean(name, Object.keys(filters))}${LINT_HINT}`);
1159
+ }
1160
+ return filter(options);
1161
+ };
1162
+
1163
+ /**
1164
+ * フィルタ引数リストのパース。`filter(a, b)` の `a, b` 部分を受け取る。
1165
+ *
1166
+ * トリムの規則は「**クォートの外側だけ**」。`fix( 2 )` のような書き癖を吸収するために
1167
+ * 素の引数は前後をトリムするが、クォートは「ここは literal」という宣言なので中身の
1168
+ * 空白は残す。両方まとめてトリムしていたため `pad(5, ' ')` が空文字パディング
1169
+ * (=無変化)に化けており、空白区切りの `join(' / ')` も指定できなかった。
1170
+ */
1171
+ /** 引数 1 つを確定する。クォート由来の文字が入った範囲より外側だけをトリムする。 */
1172
+ function finalizeArg(text, firstQuoteStart, lastQuoteEnd) {
1173
+ // 先頭側: 最初のクォート文字より前だけが削れる(クォートが無ければ全体が対象)
1174
+ const startLimit = firstQuoteStart === -1 ? text.length : firstQuoteStart;
1175
+ let start = 0;
1176
+ while (start < startLimit && /\s/.test(text[start])) {
1177
+ start++;
1178
+ }
1179
+ // 末尾側: 最後のクォート文字より後ろだけが削れる(クォートが無ければ全体が対象)
1180
+ const endLimit = lastQuoteEnd === -1 ? 0 : lastQuoteEnd;
1181
+ let end = text.length;
1182
+ while (end > endLimit && /\s/.test(text[end - 1])) {
1183
+ end--;
1184
+ }
1185
+ return text.slice(start, end);
1186
+ }
1187
+ function parseFilterArgs(argsText) {
1188
+ const args = [];
1189
+ let current = '';
1190
+ let inQuote = null;
1191
+ let hasQuote = false;
1192
+ let firstQuoteStart = -1;
1193
+ let lastQuoteEnd = -1;
1194
+ const flush = () => {
1195
+ args.push(finalizeArg(current, firstQuoteStart, lastQuoteEnd));
1196
+ current = '';
1197
+ hasQuote = false;
1198
+ firstQuoteStart = -1;
1199
+ lastQuoteEnd = -1;
1200
+ };
1201
+ for (let i = 0; i < argsText.length; i++) {
1202
+ const char = argsText[i];
1203
+ if (inQuote) {
1204
+ if (char === inQuote) {
1205
+ inQuote = null;
1206
+ }
1207
+ else {
1208
+ if (firstQuoteStart === -1) {
1209
+ firstQuoteStart = current.length;
1210
+ }
1211
+ current += char;
1212
+ lastQuoteEnd = current.length;
1213
+ }
1214
+ }
1215
+ else if (char === '"' || char === "'") {
1216
+ inQuote = char;
1217
+ hasQuote = true;
1218
+ }
1219
+ else if (char === ',') {
1220
+ flush();
1221
+ }
1222
+ else {
1223
+ current += char;
1224
+ }
1225
+ }
1226
+ const last = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
1227
+ if (last || hasQuote) {
1228
+ args.push(last);
1229
+ }
1230
+ return args;
1231
+ }
1232
+
1233
+ const filterFnByKey = new Map();
1234
+ // format: filterName(arg1,arg2) or filterName
1235
+ function parseFilters(filterTextList, filterIOType) {
1236
+ const builtinFilters = builtinFiltersByFilterIOType[filterIOType];
1237
+ const filters = filterTextList.map((filterText) => {
1238
+ const openParenIndex = filterText.indexOf('(');
1239
+ const closeParenIndex = filterText.lastIndexOf(')');
1240
+ // check parentheses
1241
+ if (openParenIndex !== -1 && closeParenIndex === -1) {
1242
+ raiseError(`Invalid filter format: missing closing parenthesis in "${filterText}"`);
1243
+ }
1244
+ if (closeParenIndex !== -1 && openParenIndex === -1) {
1245
+ raiseError(`Invalid filter format: missing opening parenthesis in "${filterText}"`);
1246
+ }
1247
+ if (openParenIndex === -1) {
1248
+ // no arguments
1249
+ const filterName = filterText.trim();
1250
+ const filterKey = `${filterName}():${filterIOType}`;
1251
+ let filterFn = filterFnByKey.get(filterKey);
1252
+ if (typeof filterFn === 'undefined') {
1253
+ filterFn = builtinFilterFn(filterName, [])(builtinFilters);
1254
+ filterFnByKey.set(filterKey, filterFn);
1255
+ }
1256
+ return {
1257
+ filterName: filterName,
1258
+ args: [],
1259
+ filterFn: filterFn,
1260
+ };
1261
+ }
1262
+ else {
1263
+ const argsText = filterText.substring(openParenIndex + 1, closeParenIndex);
1264
+ const filterName = filterText.substring(0, openParenIndex).trim();
1265
+ const args = parseFilterArgs(argsText);
1266
+ const filterKey = `${filterName}(${args.join(',')}):${filterIOType}`;
1267
+ let filterFn = filterFnByKey.get(filterKey);
1268
+ if (typeof filterFn === 'undefined') {
1269
+ filterFn = builtinFilterFn(filterName, args)(builtinFilters);
1270
+ filterFnByKey.set(filterKey, filterFn);
1271
+ }
1272
+ return {
1273
+ filterName,
1274
+ args,
1275
+ filterFn,
1276
+ };
1277
+ }
1278
+ });
1279
+ return filters;
1280
+ }
1281
+
1282
+ const trimFn = (s) => s.trim();
1283
+
1284
+ const cacheFilterInfos$1 = new Map();
1285
+ /** tooling 専用(parser.ts の clearParserCaches からのみ呼ぶ)。 */
1286
+ function clearPropPartCacheForTooling() {
1287
+ cacheFilterInfos$1.clear();
1288
+ }
1289
+ // format: propName#moodifier1,modifier2
1290
+ // propName-format: path.to.property (e.g., textContent, style.color, not include :)
1291
+ // special path:
1292
+ // 'attr.attributeName' for attributes (e.g., attr.href, attr.data-id)
1293
+ // 'style.propertyName' for style properties (e.g., style.backgroundColor, style.fontSize)
1294
+ // 'class.className' for class names (e.g., class.active, class.hidden)
1295
+ // 'onclick', 'onchange' etc. for event listeners
1296
+ function parsePropPart(propPart) {
1297
+ const pos = propPart.indexOf(FILTER_SEPARATOR);
1298
+ let propText = '';
1299
+ let filterTexts = [];
1300
+ let filtersText = '';
1301
+ let filters = [];
1302
+ if (pos !== -1) {
1303
+ propText = propPart.slice(0, pos).trim();
1304
+ filtersText = propPart.slice(pos + 1).trim();
1305
+ if (cacheFilterInfos$1.has(filtersText)) {
1306
+ filters = cacheFilterInfos$1.get(filtersText);
1307
+ }
1308
+ else {
1309
+ filterTexts = filtersText.split(FILTER_SEPARATOR).map(trimFn);
1310
+ filters = parseFilters(filterTexts, "input");
1311
+ cacheFilterInfos$1.set(filtersText, filters);
1312
+ }
1313
+ }
1314
+ else {
1315
+ propText = propPart.trim();
1316
+ }
1317
+ const [propName, propModifiersText] = propText.split(MODIFIER_SEPARATOR).map(trimFn);
1318
+ const propSegments = propName.split(DELIMITER).map(trimFn);
1319
+ const propModifiers = propModifiersText
1320
+ ? propModifiersText.split(',').map(trimFn)
1321
+ : [];
1322
+ return {
1323
+ propName,
1324
+ propSegments,
1325
+ propModifiers,
1326
+ inFilters: filters,
1327
+ };
1328
+ }
1329
+
1330
+ const cacheFilterInfos = new Map();
1331
+ /** tooling 専用(parser.ts の clearParserCaches からのみ呼ぶ)。 */
1332
+ function clearStatePartCacheForTooling() {
1333
+ cacheFilterInfos.clear();
1334
+ }
1335
+ // format: statePath@stateName|filter|filter
1336
+ // statePath-format: path.to.property (e.g., user.name.first, users.*.name, users.0.name, not include @)
1337
+ // stateName: optional, default is 'default'
1338
+ // filters-format: filterName or filterName(arg1,arg2)
1339
+ function parseStatePart(statePart) {
1340
+ const pos = statePart.indexOf(FILTER_SEPARATOR);
1341
+ let stateAndPath = '';
1342
+ let filterTexts = [];
1343
+ let filtersText = '';
1344
+ let filters = [];
1345
+ if (pos !== -1) {
1346
+ stateAndPath = statePart.slice(0, pos).trim();
1347
+ filtersText = statePart.slice(pos + 1).trim();
1348
+ if (cacheFilterInfos.has(filtersText)) {
1349
+ filters = cacheFilterInfos.get(filtersText);
1350
+ }
1351
+ else {
1352
+ filterTexts = filtersText.split(FILTER_SEPARATOR).map(trimFn);
1353
+ filters = parseFilters(filterTexts, "output");
1354
+ cacheFilterInfos.set(filtersText, filters);
1355
+ }
1356
+ }
1357
+ else {
1358
+ stateAndPath = statePart.trim();
1359
+ }
1360
+ const [statePathName, stateName = 'default'] = stateAndPath.split(STATE_NAME_SEPARATOR).map(trimFn);
1361
+ const pathInfo = getPathInfo(statePathName);
1362
+ return {
1363
+ stateName,
1364
+ statePathName,
1365
+ statePathInfo: pathInfo,
1366
+ outFilters: filters,
1367
+ };
1368
+ }
1369
+
1370
+ // format: propPart:statePart; propPart:statePart; ...
1371
+ // special-propPart:
1372
+ // if: statePart (single binding for conditional rendering)
1373
+ // else: (single binding for conditional rendering, and statePart is ignored)
1374
+ // elseif: statePart only (single binding for conditional rendering)
1375
+ // for: statePart only (single binding for loop rendering)
1376
+ // onclick: statePart, onchange: statePart etc. (event listeners)
1377
+ // ...: statePart (spread — expand wcBindable properties+inputs of target object)
1378
+ function parseBindTextsForElement(bindText) {
1379
+ const [...bindTexts] = bindText.split(BINDING_SEPARATOR).map(trimFn).filter(s => s.length > 0);
1380
+ const results = bindTexts.map((bindText) => {
1381
+ const separatorIndex = bindText.indexOf(PROP_VALUE_SEPARATOR);
1382
+ if (separatorIndex === -1) {
1383
+ raiseError(`Invalid bindText: "${bindText}". Missing ':' separator between propPart and statePart.`);
1384
+ }
1385
+ const propPart = bindText.slice(0, separatorIndex).trim();
1386
+ const statePart = bindText.slice(separatorIndex + 1).trim();
1387
+ if (propPart === ELSE_KEYWORD) {
1388
+ const pathInfo = getPathInfo('#else');
1389
+ return {
1390
+ propName: ELSE_KEYWORD,
1391
+ propSegments: [ELSE_KEYWORD],
1392
+ propModifiers: [],
1393
+ statePathName: '#else',
1394
+ statePathInfo: pathInfo,
1395
+ stateName: '',
1396
+ inFilters: [],
1397
+ outFilters: [],
1398
+ bindingType: 'else',
1399
+ };
1400
+ }
1401
+ else if (propPart === SPREAD_PROP) {
1402
+ const stateResult = parseStatePart(statePart);
1403
+ if (stateResult.outFilters.length > 0) {
1404
+ raiseError(`Invalid spread binding "${bindText}": filters are not allowed on spread targets.`);
1405
+ }
1406
+ if (stateResult.statePathName.length === 0) {
1407
+ raiseError(`Invalid spread binding "${bindText}": spread target path is required.`);
1408
+ }
1409
+ return {
1410
+ propName: SPREAD_PROP,
1411
+ propSegments: [SPREAD_PROP],
1412
+ propModifiers: [],
1413
+ inFilters: [],
1414
+ ...stateResult,
1415
+ bindingType: 'spread',
1416
+ };
1417
+ }
1418
+ else if (propPart === 'if'
1419
+ || propPart === 'elseif'
1420
+ || propPart === 'for'
1421
+ || propPart === 'radio'
1422
+ || propPart === 'checkbox') {
1423
+ const stateResult = parseStatePart(statePart);
1424
+ return {
1425
+ propName: propPart,
1426
+ propSegments: [propPart],
1427
+ propModifiers: [],
1428
+ inFilters: [],
1429
+ ...stateResult,
1430
+ bindingType: propPart,
1431
+ };
1432
+ }
1433
+ else {
1434
+ const stateResult = parseStatePart(statePart);
1435
+ const propResult = parsePropPart(propPart);
1436
+ // eventToken.<prop>: <name> は要素 dispatch を state へ流す pub/sub 配線。
1437
+ // 値適用ではないため bindingType 'event' として listener attach 経路に乗せる。
1438
+ if (propResult.propSegments[0] === EVENT_TOKEN_NAMESPACE) {
1439
+ return {
1440
+ ...propResult,
1441
+ ...stateResult,
1442
+ bindingType: 'event',
1443
+ };
1444
+ }
1445
+ if (propResult.propSegments[0].startsWith(EVENT_PROP_PREFIX)) {
1446
+ return {
1447
+ ...propResult,
1448
+ ...stateResult,
1449
+ bindingType: 'event',
1450
+ };
1451
+ }
1452
+ else {
1453
+ return {
1454
+ ...propResult,
1455
+ ...stateResult,
1456
+ bindingType: 'prop',
1457
+ };
1458
+ }
1459
+ }
1460
+ });
1461
+ // check for sigle binding for 'if', 'elseif', 'else', 'for'
1462
+ if (results.length > 1) {
1463
+ const isIncludeSingleBinding = results.some(r => STRUCTURAL_BINDING_TYPE_SET.has(r.bindingType));
1464
+ if (isIncludeSingleBinding) {
1465
+ // lint 側の単独バインディング検査(bindingValidator の structuralMustBeSingle)が
1466
+ // 同じケースを検出するため誘導を付ける(三面同語彙)。
1467
+ raiseError(`[wcs/template-syntax] Invalid bindText: "${bindText}". 'if', 'elseif', 'else', and 'for' bindings must be single binding. Put the structural binding alone in its own data-wcs (e.g. <template data-wcs="for: items">).${LINT_HINT}`);
1468
+ }
1469
+ }
1470
+ return results;
1471
+ }
1472
+
1473
+ function parseBindTextForEmbeddedNode(bindText) {
1474
+ const stateResult = parseStatePart(bindText);
1475
+ return {
1476
+ propName: 'textContent',
1477
+ propSegments: ['textContent'],
1478
+ propModifiers: [],
1479
+ inFilters: [],
1480
+ ...stateResult,
1481
+ bindingType: 'text',
1482
+ };
1483
+ }
1484
+
1485
+ /**
1486
+ * parser.ts — `data-wcs` バインディング構文の正本パーサを tooling 向けに公開する
1487
+ * サブパスエントリ(`@wcstack/state/parser`)。
1488
+ *
1489
+ * `./manifest` と同じ「実装が唯一の正本」パターン(docs/static-wiring-dx-design.md D2)。
1490
+ * vscode-wcs の正規表現パーサ・devtools の declaredScan 簡易パーサという複製実装を
1491
+ * 段階的にこの正本へ寄せるための土台。
1492
+ *
1493
+ * 契約:
1494
+ * - DOM 非依存・純関数(bindText 文字列 → ParseBindTextResult[])。Node でそのまま動く
1495
+ * (__tests__/parser.test.ts が node 環境で検証する)。
1496
+ * - **位置情報は持たず、不正構文は raiseError で throw する**。エラー耐性と診断 range の
1497
+ * 生成は消費側(vscode-wcs の positional ラッパー)の責務(同 D3)— ランタイムの
1498
+ * サイズと責務をここで増やさない。
1499
+ * - `getPathInfo` はパス文字列の解析済みビュー(セグメント・ワイルドカード位置・親パス
1500
+ * チェーン)を返す純関数。静的依存グラフの親チェーン展開はこの情報から機械的に再現できる。
1501
+ * 同一パス → 同一インスタンスの保証は**このエントリのモジュールインスタンス内**でのみ
1502
+ * 成立する(`.` エントリは別バンドル=別キャッシュ。ランタイムの PathInfo と identity
1503
+ * 比較してはならない)。キャッシュは無制限(evict なし)— 言語サーバー等の長時間
1504
+ * プロセスでは入力パス種数に単調比例してメモリが増える点に留意。
1505
+ * - `ParseBindTextResult.uuid` はランタイム内部(構造テンプレートのハイドレーション台帳)
1506
+ * 用のフィールドで、このパーサの戻り値では常に undefined。
1507
+ *
1508
+ * 公開面は意図的に最小(公開=恒久契約)。`expandSpread` は live Element と
1509
+ * CustomElementRegistry を要するためここには含めない — ブラウザ内の消費者
1510
+ * (devtools の declared 正本化)は state 自身が pull API で答える。
1511
+ */
1512
+ /**
1513
+ * このエントリの内部キャッシュ(PathInfo intern・フィルタ列パース結果)を全て捨てる。
1514
+ *
1515
+ * 言語サーバー等の**長時間プロセス専用**。編集中の中間パス(`user.n` 等)が
1516
+ * 無制限キャッシュに恒久 intern されてメモリが単調増加するため、ドキュメント
1517
+ * クローズ等の区切りで呼ぶ。クリア後の getPathInfo は同一パスに**新しい**
1518
+ * インスタンスを返す — 「同一パス → 同一参照」の保証はクリアを跨がない。
1519
+ * ランタイム(`.` エントリ)にはこの API は無く、呼ばれることもない。
1520
+ */
1521
+ function clearParserCaches() {
1522
+ clearPathInfoCacheForTooling();
1523
+ clearPropPartCacheForTooling();
1524
+ clearStatePartCacheForTooling();
1525
+ }
1526
+
1527
+ export { clearParserCaches, getPathInfo, parseBindTextForEmbeddedNode, parseBindTextsForElement };