@wcstack/state 2.6.1 → 3.0.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/README.ja.md +91 -46
- package/README.md +93 -46
- package/dist/auto.min.js +1 -1
- package/dist/auto.min.js.map +1 -1
- package/dist/define.d.ts +295 -0
- package/dist/define.js +1 -0
- package/dist/index.d.ts +252 -94
- package/dist/index.esm.js +1 -23477
- package/dist/index.esm.js.map +1 -1
- package/dist/manifest.esm.js +21 -37
- package/dist/parser.d.ts +37 -39
- package/dist/parser.esm.js +147 -1153
- package/dist/split/chunks/binder.js +2 -0
- package/dist/split/chunks/binder.js.map +1 -0
- package/dist/split/chunks/checkDependency.js +2 -0
- package/dist/split/chunks/checkDependency.js.map +1 -0
- package/dist/split/chunks/diagnosticsHooks.js +2 -0
- package/dist/split/chunks/diagnosticsHooks.js.map +1 -0
- package/dist/split/chunks/errorGuidance.js +2 -0
- package/dist/split/chunks/errorGuidance.js.map +1 -0
- package/dist/split/chunks/features.d.ts +29 -0
- package/dist/split/chunks/filterRegistry.js +2 -0
- package/dist/split/chunks/filterRegistry.js.map +1 -0
- package/dist/split/chunks/getAllPropertyDescriptors.js +2 -0
- package/dist/split/chunks/getAllPropertyDescriptors.js.map +1 -0
- package/dist/split/chunks/lifecycleHooks.js +2 -0
- package/dist/split/chunks/lifecycleHooks.js.map +1 -0
- package/dist/split/chunks/mount.js +2 -0
- package/dist/split/chunks/mount.js.map +1 -0
- package/dist/split/chunks/pathDiagnostics.js +2 -0
- package/dist/split/chunks/pathDiagnostics.js.map +1 -0
- package/dist/split/chunks/registerComponents.js +2 -0
- package/dist/split/chunks/registerComponents.js.map +1 -0
- package/dist/split/chunks/setAll.js +2 -0
- package/dist/split/chunks/setAll.js.map +1 -0
- package/dist/split/chunks/version.js +2 -0
- package/dist/split/chunks/version.js.map +1 -0
- package/dist/split/chunks/watchRuntime.js +2 -0
- package/dist/split/chunks/watchRuntime.js.map +1 -0
- package/dist/split/core.d.ts +1417 -0
- package/dist/split/core.js +2 -0
- package/dist/split/core.js.map +1 -0
- package/dist/split/features/devtools.d.ts +9 -0
- package/dist/split/features/devtools.js +2 -0
- package/dist/split/features/devtools.js.map +1 -0
- package/dist/split/features/diagnostics.d.ts +11 -0
- package/dist/split/features/diagnostics.js +2 -0
- package/dist/split/features/diagnostics.js.map +1 -0
- package/dist/split/features/formats.d.ts +10 -0
- package/dist/split/features/formats.js +2 -0
- package/dist/split/features/formats.js.map +1 -0
- package/dist/split/features/recursion.d.ts +9 -0
- package/dist/split/features/recursion.js +2 -0
- package/dist/split/features/recursion.js.map +1 -0
- package/dist/split/features/scopes.d.ts +10 -0
- package/dist/split/features/scopes.js +2 -0
- package/dist/split/features/scopes.js.map +1 -0
- package/dist/split/features/ssr.d.ts +9 -0
- package/dist/split/features/ssr.js +2 -0
- package/dist/split/features/ssr.js.map +1 -0
- package/dist/split/features/temporal.d.ts +9 -0
- package/dist/split/features/temporal.js +2 -0
- package/dist/split/features/temporal.js.map +1 -0
- package/package.json +13 -1
package/dist/parser.esm.js
CHANGED
|
@@ -183,51 +183,6 @@ class PathInfo {
|
|
|
183
183
|
* 安定診断 code(packages/vscode-wcs/src/core/diagnostics.ts)と同一。
|
|
184
184
|
*/
|
|
185
185
|
/** 挿入・削除・置換の編集距離。長さ差が max を超えたら早期に max+1 を返す。 */
|
|
186
|
-
function editDistance(a, b, max) {
|
|
187
|
-
if (Math.abs(a.length - b.length) > max) {
|
|
188
|
-
return max + 1;
|
|
189
|
-
}
|
|
190
|
-
const prev = new Array(b.length + 1);
|
|
191
|
-
const curr = new Array(b.length + 1);
|
|
192
|
-
for (let j = 0; j <= b.length; j++) {
|
|
193
|
-
prev[j] = j;
|
|
194
|
-
}
|
|
195
|
-
for (let i = 1; i <= a.length; i++) {
|
|
196
|
-
curr[0] = i;
|
|
197
|
-
for (let j = 1; j <= b.length; j++) {
|
|
198
|
-
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
199
|
-
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
|
|
200
|
-
}
|
|
201
|
-
for (let j = 0; j <= b.length; j++) {
|
|
202
|
-
prev[j] = curr[j];
|
|
203
|
-
}
|
|
204
|
-
}
|
|
205
|
-
return prev[b.length];
|
|
206
|
-
}
|
|
207
|
-
/**
|
|
208
|
-
* 候補集合から編集距離 2 以内の最近傍を探し、` Did you mean "<best>"?` を返す。
|
|
209
|
-
* 該当なしは空文字。規準(距離 2・同距離は先勝ち・大小文字は畳んで比較)は
|
|
210
|
-
* lint の did-you-mean(ioNodeValidator の suggestion)と同じ — 三面で提案が
|
|
211
|
-
* 割れないように揃えている。動的キー等で候補が列挙できないサイトでは呼ばない
|
|
212
|
-
* = 誘導文のみに縮退(設計 §3 の縮退)。
|
|
213
|
-
*/
|
|
214
|
-
function didYouMean(input, candidates) {
|
|
215
|
-
// 空入力(`a|` の末尾パイプ等)に短い候補を提案しても無意味なので出さない。
|
|
216
|
-
if (input.length === 0) {
|
|
217
|
-
return "";
|
|
218
|
-
}
|
|
219
|
-
const folded = input.toLowerCase();
|
|
220
|
-
let best = null;
|
|
221
|
-
let bestDistance = 3;
|
|
222
|
-
for (const candidate of candidates) {
|
|
223
|
-
const distance = editDistance(folded, candidate.toLowerCase(), 2);
|
|
224
|
-
if (distance < bestDistance) {
|
|
225
|
-
best = candidate;
|
|
226
|
-
bestDistance = distance;
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
return best !== null ? ` Did you mean "${best}"?` : "";
|
|
230
|
-
}
|
|
231
186
|
/**
|
|
232
187
|
* lint への誘導(誘導付きメッセージ共通の一文)。
|
|
233
188
|
* **lint が実際にそのケースを検出するサイトにだけ付ける** — 検出しないケースに
|
|
@@ -247,1070 +202,23 @@ const STRUCTURAL_BINDING_TYPE_SET = new Set([
|
|
|
247
202
|
"for",
|
|
248
203
|
]);
|
|
249
204
|
|
|
250
|
-
const _config = {
|
|
251
|
-
locale: 'en'};
|
|
252
|
-
// backward compatible export (read-only usage)
|
|
253
|
-
const config = _config;
|
|
254
|
-
|
|
255
|
-
/**
|
|
256
|
-
* errorMessages.ts
|
|
257
|
-
*
|
|
258
|
-
* Error message generation utilities used by filter functions.
|
|
259
|
-
*
|
|
260
|
-
* Main responsibilities:
|
|
261
|
-
* - Throws clear error messages when filter options or value type checks fail
|
|
262
|
-
* - Takes function name as argument to specify which filter caused the error
|
|
263
|
-
*
|
|
264
|
-
* Design points:
|
|
265
|
-
* - optionsRequired: Error when required option is not specified
|
|
266
|
-
* - optionMustBeNumber: Error when option value is not a number
|
|
267
|
-
* - valueMustBeNumber: Error when value is not a number
|
|
268
|
-
* - valueMustBeBoolean: Error when value is not boolean
|
|
269
|
-
* - valueMustBeDate: Error when value is not a Date
|
|
270
|
-
*/
|
|
271
|
-
/**
|
|
272
|
-
* Throws error when filter requires at least one option but none provided.
|
|
273
|
-
*
|
|
274
|
-
* @param fnName - Name of the filter function
|
|
275
|
-
* @returns Never returns (always throws)
|
|
276
|
-
*/
|
|
277
|
-
function optionsRequired(fnName) {
|
|
278
|
-
raiseError(`filter ${fnName} requires at least one option`);
|
|
279
|
-
}
|
|
280
|
-
/**
|
|
281
|
-
* Throws error when filter option must be a number but invalid value provided.
|
|
282
|
-
*
|
|
283
|
-
* @param fnName - Name of the filter function
|
|
284
|
-
* @returns Never returns (always throws)
|
|
285
|
-
*/
|
|
286
|
-
function optionMustBeNumber(fnName) {
|
|
287
|
-
raiseError(`filter ${fnName} requires a number as option`);
|
|
288
|
-
}
|
|
289
|
-
/**
|
|
290
|
-
* Throws error when filter requires numeric value but non-number provided.
|
|
291
|
-
*
|
|
292
|
-
* @param fnName - Name of the filter function
|
|
293
|
-
* @returns Never returns (always throws)
|
|
294
|
-
*/
|
|
295
|
-
function valueMustBeNumber(fnName) {
|
|
296
|
-
raiseError(`filter ${fnName} requires a number value`);
|
|
297
|
-
}
|
|
298
|
-
/**
|
|
299
|
-
* Throws error when filter requires boolean value but non-boolean provided.
|
|
300
|
-
*
|
|
301
|
-
* @param fnName - Name of the filter function
|
|
302
|
-
* @returns Never returns (always throws)
|
|
303
|
-
*/
|
|
304
|
-
function valueMustBeBoolean(fnName) {
|
|
305
|
-
raiseError(`filter ${fnName} requires a boolean value`);
|
|
306
|
-
}
|
|
307
|
-
/**
|
|
308
|
-
* Throws error when filter requires Date value but non-Date provided.
|
|
309
|
-
*
|
|
310
|
-
* @param fnName - Name of the filter function
|
|
311
|
-
* @returns Never returns (always throws)
|
|
312
|
-
*/
|
|
313
|
-
function valueMustBeDate(fnName) {
|
|
314
|
-
raiseError(`filter ${fnName} requires a date value`);
|
|
315
|
-
}
|
|
316
|
-
/**
|
|
317
|
-
* Throws error when filter requires array value but non-array provided.
|
|
318
|
-
*
|
|
319
|
-
* @param fnName - Name of the filter function
|
|
320
|
-
* @returns Never returns (always throws)
|
|
321
|
-
*/
|
|
322
|
-
function valueMustBeArray(fnName) {
|
|
323
|
-
raiseError(`filter ${fnName} requires an array value`);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
// 組み込みフィルタの引数の上限。正本は filters/filterMeta.ts の maxArgs だが、説明文ごとランタイムに
|
|
327
|
-
// 載せないためここに畳む(一致はテストが固定する)。2.x の組み込みに無い名前はパーサが先に拒否する。
|
|
328
|
-
// 配列リテラルで書く — split() の呼び出しは副作用ありとみなされ、defineState だけの import に残る。
|
|
329
|
-
const NO_ARGS = new Set([
|
|
330
|
-
"not", "abs", "uc", "lc", "cap", "trim", "rev", "int", "float", "date", "time", "datetime",
|
|
331
|
-
"falsy", "truthy", "boolean", "number", "string", "null",
|
|
332
|
-
]);
|
|
333
|
-
const TWO_ARGS = new Set(["clamp", "slice", "substr", "pad", "truncate"]);
|
|
334
|
-
/** 組み込みフィルタ name が受け取る引数の上限 */
|
|
335
|
-
function maxFilterArgs(name) {
|
|
336
|
-
return NO_ARGS.has(name) ? 0 : TWO_ARGS.has(name) ? 2 : 1;
|
|
337
|
-
}
|
|
338
|
-
const STRUCTURAL_KEYWORDS = new Set(["for", "if", "elseif", "else", "..."]);
|
|
339
|
-
/**
|
|
340
|
-
* 引用符の無い true / false / null を 1 つだけ取る eq / ne / defaults。3.0 は型付きの値として読む(要件 B9)。
|
|
341
|
-
* 2.x はフィルタに渡す前に引用符を剥がすので、値の側では `eq('true')` と区別できない — 原文で見る。
|
|
342
|
-
*/
|
|
343
|
-
const TYPED_LITERAL = /(?:^|\|)\s*(eq|ne|defaults)\s*\(\s*(true|false|null)\s*\)/g;
|
|
344
|
-
/** 閉じていない引用符があるか */
|
|
345
|
-
function hasUnterminatedQuote(text) {
|
|
346
|
-
let quote = null;
|
|
347
|
-
for (let i = 0; i < text.length; i++) {
|
|
348
|
-
const c = text[i];
|
|
349
|
-
if (quote !== null) {
|
|
350
|
-
if (c === quote)
|
|
351
|
-
quote = null;
|
|
352
|
-
}
|
|
353
|
-
else if (c === "'" || c === '"') {
|
|
354
|
-
quote = c;
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return quote !== null;
|
|
358
|
-
}
|
|
359
|
-
/** 右辺(とフィルタ)の判定。属性の式とテキストバインディングで共通 */
|
|
360
|
-
function checkStatePart(text, parsed, issues) {
|
|
361
|
-
if (hasUnterminatedQuote(text)) {
|
|
362
|
-
issues.push(`"${text}": 3.0 rejects the unterminated quote.`);
|
|
363
|
-
}
|
|
364
|
-
for (const [, fnName, literal] of text.matchAll(TYPED_LITERAL)) {
|
|
365
|
-
issues.push(`"${fnName}(${literal})": 3.0 reads an unquoted ${literal} as a ${literal === "null" ? "null" : "boolean"}, ` +
|
|
366
|
-
`not the text. Write ${fnName}('${literal}') to keep the text.`);
|
|
367
|
-
}
|
|
368
|
-
if (parsed !== null) {
|
|
369
|
-
issues.push(...findFilterArityIssues([parsed]));
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
/** 解析済みのフィルタの引数の個数(3.0 は束縛計画の段で [wcs/filter-arity] として拒否する) */
|
|
373
|
-
function findFilterArityIssues(results) {
|
|
374
|
-
const issues = [];
|
|
375
|
-
for (const result of results) {
|
|
376
|
-
for (const filter of [...result.inFilters, ...result.outFilters]) {
|
|
377
|
-
const max = maxFilterArgs(filter.filterName);
|
|
378
|
-
if (filter.args.length > max) {
|
|
379
|
-
issues.push(`"${filter.filterName}" takes at most ${max} argument(s); 3.0 rejects more.`);
|
|
380
|
-
}
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
return issues;
|
|
384
|
-
}
|
|
385
|
-
/**
|
|
386
|
-
* `data-wcs` の式 1 つ(`;` を含まない、trim 済み)の判定。`parsed` があればフィルタの引数の個数も見る。
|
|
387
|
-
* 区切りの無い式は 2.x のパーサが先に拒否するので何も言わない。
|
|
388
|
-
*/
|
|
389
|
-
function findV3MigrationIssues(expr, parsed = null) {
|
|
390
|
-
const issues = [];
|
|
391
|
-
const colon = expr.indexOf(":");
|
|
392
|
-
if (colon === -1)
|
|
393
|
-
return issues;
|
|
394
|
-
const propPart = expr.slice(0, colon).trim();
|
|
395
|
-
const modifierParts = propPart.split("#");
|
|
396
|
-
const keyword = modifierParts[0].split("|")[0].trim();
|
|
397
|
-
if (modifierParts.length > 2) {
|
|
398
|
-
issues.push(`"${propPart}": 3.0 rejects a second "#". Write "${modifierParts[0].trim()}#${modifierParts.slice(1).map((m) => m.trim()).join(",")}".`);
|
|
399
|
-
}
|
|
400
|
-
if (keyword === "else" && expr.slice(colon + 1).trim().length > 0) {
|
|
401
|
-
issues.push(`"${expr}": 3.0 rejects a value after "else:".`);
|
|
402
|
-
}
|
|
403
|
-
if (STRUCTURAL_KEYWORDS.has(keyword) && keyword !== propPart) {
|
|
404
|
-
issues.push(`"${propPart}": 3.0 rejects modifiers and filters on "${keyword}". Write "${keyword}:".`);
|
|
405
|
-
}
|
|
406
|
-
if ((keyword === "radio" || keyword === "checkbox") && keyword !== propPart) {
|
|
407
|
-
issues.push(`"${propPart}": 3.0 keeps this a ${keyword} binding that honours the modifiers (2.x binds a property named "${keyword}").`);
|
|
408
|
-
}
|
|
409
|
-
checkStatePart(expr.slice(colon + 1).trim(), parsed, issues);
|
|
410
|
-
return issues;
|
|
411
|
-
}
|
|
412
|
-
/** mustache / コメントのテキストバインディング(右辺だけ、`;` で割らない)の判定 */
|
|
413
|
-
function findEmbeddedV3MigrationIssues(expression, parsed = null) {
|
|
414
|
-
const issues = [];
|
|
415
|
-
checkStatePart(expression, parsed, issues);
|
|
416
|
-
return issues;
|
|
417
|
-
}
|
|
418
|
-
|
|
419
|
-
const warned = new Set();
|
|
420
|
-
/** 同じ文面は 1 回だけ警告する(文面は書き方とサイトを含むので、書き方・サイトごとに 1 回) */
|
|
421
|
-
function warnV3Migration(message) {
|
|
422
|
-
if (warned.has(message)) {
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
warned.add(message);
|
|
426
|
-
console.warn(`[@wcstack/state] [wcs/v3-migration] ${message} See "Preparing for 3.0" in the @wcstack/state README.`);
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
/**
|
|
430
|
-
* builtinFilters.ts
|
|
431
|
-
*
|
|
432
|
-
* Implementation file for built-in filter functions available in Structive.
|
|
433
|
-
*
|
|
434
|
-
* Main responsibilities:
|
|
435
|
-
* - Provides filters for conversion, comparison, formatting, and validation of numbers, strings, dates, booleans, etc.
|
|
436
|
-
* - Defines functions with options for each filter name, enabling flexible use during binding
|
|
437
|
-
* - Designed for common use as both input and output filters
|
|
438
|
-
*
|
|
439
|
-
* Design points:
|
|
440
|
-
* - 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.
|
|
441
|
-
* - Rich type checking and error handling for option values
|
|
442
|
-
* - Centralized management of filter functions with FilterWithOptions type, easy to extend
|
|
443
|
-
* - Dynamic retrieval of filter functions from filter names and options via builtinFilterFn
|
|
444
|
-
*/
|
|
445
|
-
function validateNumberString(value) {
|
|
446
|
-
if (!value || isNaN(Number(value))) {
|
|
447
|
-
return false;
|
|
448
|
-
}
|
|
449
|
-
return true;
|
|
450
|
-
}
|
|
451
|
-
/**
|
|
452
|
-
* Equality filter - compares value with option.
|
|
453
|
-
*
|
|
454
|
-
* @param options - Array with comparison value as first element
|
|
455
|
-
* @returns Filter function that returns boolean
|
|
456
|
-
*/
|
|
457
|
-
const eq = (options) => {
|
|
458
|
-
const opt = options?.[0] ?? optionsRequired('eq');
|
|
459
|
-
return (value) => {
|
|
460
|
-
// Align types for comparison
|
|
461
|
-
if (typeof value === 'number') {
|
|
462
|
-
if (!validateNumberString(opt)) {
|
|
463
|
-
optionMustBeNumber('eq');
|
|
464
|
-
}
|
|
465
|
-
return value === Number(opt);
|
|
466
|
-
}
|
|
467
|
-
if (typeof value === 'string') {
|
|
468
|
-
return value === opt;
|
|
469
|
-
}
|
|
470
|
-
// Strict equality for others
|
|
471
|
-
return value === opt;
|
|
472
|
-
};
|
|
473
|
-
};
|
|
474
|
-
/**
|
|
475
|
-
* Inequality filter - compares value with option.
|
|
476
|
-
*
|
|
477
|
-
* @param options - Array with comparison value as first element
|
|
478
|
-
* @returns Filter function that returns boolean
|
|
479
|
-
*/
|
|
480
|
-
const ne = (options) => {
|
|
481
|
-
const opt = options?.[0] ?? optionsRequired('ne');
|
|
482
|
-
return (value) => {
|
|
483
|
-
// Align types for comparison
|
|
484
|
-
if (typeof value === 'number') {
|
|
485
|
-
if (!validateNumberString(opt)) {
|
|
486
|
-
optionMustBeNumber('ne');
|
|
487
|
-
}
|
|
488
|
-
return value !== Number(opt);
|
|
489
|
-
}
|
|
490
|
-
if (typeof value === 'string') {
|
|
491
|
-
return value !== opt;
|
|
492
|
-
}
|
|
493
|
-
// Strict equality for others
|
|
494
|
-
return value !== opt;
|
|
495
|
-
};
|
|
496
|
-
};
|
|
497
|
-
/**
|
|
498
|
-
* Boolean NOT filter - inverts boolean value.
|
|
499
|
-
*
|
|
500
|
-
* @param options - Unused
|
|
501
|
-
* @returns Filter function that returns inverted boolean
|
|
502
|
-
*/
|
|
503
|
-
const not = (_options) => {
|
|
504
|
-
return (value) => {
|
|
505
|
-
if (typeof value !== 'boolean') {
|
|
506
|
-
valueMustBeBoolean('not');
|
|
507
|
-
}
|
|
508
|
-
return !value;
|
|
509
|
-
};
|
|
510
|
-
};
|
|
511
|
-
/**
|
|
512
|
-
* Less than filter - checks if value is less than option.
|
|
513
|
-
*
|
|
514
|
-
* @param options - Array with comparison number as first element
|
|
515
|
-
* @returns Filter function that returns boolean
|
|
516
|
-
*/
|
|
517
|
-
const lt = (options) => {
|
|
518
|
-
const opt = options?.[0] ?? optionsRequired('lt');
|
|
519
|
-
if (!validateNumberString(opt)) {
|
|
520
|
-
optionMustBeNumber('lt');
|
|
521
|
-
}
|
|
522
|
-
return (value) => {
|
|
523
|
-
if (typeof value !== 'number') {
|
|
524
|
-
valueMustBeNumber('lt');
|
|
525
|
-
}
|
|
526
|
-
return value < Number(opt);
|
|
527
|
-
};
|
|
528
|
-
};
|
|
529
|
-
/**
|
|
530
|
-
* Less than or equal filter - checks if value is less than or equal to option.
|
|
531
|
-
*
|
|
532
|
-
* @param options - Array with comparison number as first element
|
|
533
|
-
* @returns Filter function that returns boolean
|
|
534
|
-
*/
|
|
535
|
-
const le = (options) => {
|
|
536
|
-
const opt = options?.[0] ?? optionsRequired('le');
|
|
537
|
-
if (!validateNumberString(opt)) {
|
|
538
|
-
optionMustBeNumber('le');
|
|
539
|
-
}
|
|
540
|
-
return (value) => {
|
|
541
|
-
if (typeof value !== 'number') {
|
|
542
|
-
valueMustBeNumber('le');
|
|
543
|
-
}
|
|
544
|
-
return value <= Number(opt);
|
|
545
|
-
};
|
|
546
|
-
};
|
|
547
|
-
/**
|
|
548
|
-
* Greater than filter - checks if value is greater than option.
|
|
549
|
-
*
|
|
550
|
-
* @param options - Array with comparison number as first element
|
|
551
|
-
* @returns Filter function that returns boolean
|
|
552
|
-
*/
|
|
553
|
-
const gt = (options) => {
|
|
554
|
-
const opt = options?.[0] ?? optionsRequired('gt');
|
|
555
|
-
if (!validateNumberString(opt)) {
|
|
556
|
-
optionMustBeNumber('gt');
|
|
557
|
-
}
|
|
558
|
-
return (value) => {
|
|
559
|
-
if (typeof value !== 'number') {
|
|
560
|
-
valueMustBeNumber('gt');
|
|
561
|
-
}
|
|
562
|
-
return value > Number(opt);
|
|
563
|
-
};
|
|
564
|
-
};
|
|
565
|
-
/**
|
|
566
|
-
* Greater than or equal filter - checks if value is greater than or equal to option.
|
|
567
|
-
*
|
|
568
|
-
* @param options - Array with comparison number as first element
|
|
569
|
-
* @returns Filter function that returns boolean
|
|
570
|
-
*/
|
|
571
|
-
const ge = (options) => {
|
|
572
|
-
const opt = options?.[0] ?? optionsRequired('ge');
|
|
573
|
-
if (!validateNumberString(opt)) {
|
|
574
|
-
optionMustBeNumber('ge');
|
|
575
|
-
}
|
|
576
|
-
return (value) => {
|
|
577
|
-
if (typeof value !== 'number') {
|
|
578
|
-
valueMustBeNumber('ge');
|
|
579
|
-
}
|
|
580
|
-
return value >= Number(opt);
|
|
581
|
-
};
|
|
582
|
-
};
|
|
583
|
-
/**
|
|
584
|
-
* Increment filter - adds option value to input value.
|
|
585
|
-
*
|
|
586
|
-
* @param options - Array with increment number as first element
|
|
587
|
-
* @returns Filter function that returns incremented number
|
|
588
|
-
*/
|
|
589
|
-
const inc = (options) => {
|
|
590
|
-
const opt = options?.[0] ?? optionsRequired('inc');
|
|
591
|
-
if (!validateNumberString(opt)) {
|
|
592
|
-
optionMustBeNumber('inc');
|
|
593
|
-
}
|
|
594
|
-
return (value) => {
|
|
595
|
-
if (typeof value !== 'number') {
|
|
596
|
-
valueMustBeNumber('inc');
|
|
597
|
-
}
|
|
598
|
-
return value + Number(opt);
|
|
599
|
-
};
|
|
600
|
-
};
|
|
601
|
-
/**
|
|
602
|
-
* Decrement filter - subtracts option value from input value.
|
|
603
|
-
*
|
|
604
|
-
* @param options - Array with decrement number as first element
|
|
605
|
-
* @returns Filter function that returns decremented number
|
|
606
|
-
*/
|
|
607
|
-
const dec = (options) => {
|
|
608
|
-
const opt = options?.[0] ?? optionsRequired('dec');
|
|
609
|
-
if (!validateNumberString(opt)) {
|
|
610
|
-
optionMustBeNumber('dec');
|
|
611
|
-
}
|
|
612
|
-
return (value) => {
|
|
613
|
-
if (typeof value !== 'number') {
|
|
614
|
-
valueMustBeNumber('dec');
|
|
615
|
-
}
|
|
616
|
-
return value - Number(opt);
|
|
617
|
-
};
|
|
618
|
-
};
|
|
619
|
-
/**
|
|
620
|
-
* Multiply filter - multiplies value by option.
|
|
621
|
-
*
|
|
622
|
-
* @param options - Array with multiplier number as first element
|
|
623
|
-
* @returns Filter function that returns multiplied number
|
|
624
|
-
*/
|
|
625
|
-
const mul = (options) => {
|
|
626
|
-
const opt = options?.[0] ?? optionsRequired('mul');
|
|
627
|
-
if (!validateNumberString(opt)) {
|
|
628
|
-
optionMustBeNumber('mul');
|
|
629
|
-
}
|
|
630
|
-
return (value) => {
|
|
631
|
-
if (typeof value !== 'number') {
|
|
632
|
-
valueMustBeNumber('mul');
|
|
633
|
-
}
|
|
634
|
-
return value * Number(opt);
|
|
635
|
-
};
|
|
636
|
-
};
|
|
637
|
-
/**
|
|
638
|
-
* Divide filter - divides value by option.
|
|
639
|
-
*
|
|
640
|
-
* @param options - Array with divisor number as first element
|
|
641
|
-
* @returns Filter function that returns divided number
|
|
642
|
-
*/
|
|
643
|
-
const div = (options) => {
|
|
644
|
-
const opt = options?.[0] ?? optionsRequired('div');
|
|
645
|
-
if (!validateNumberString(opt)) {
|
|
646
|
-
optionMustBeNumber('div');
|
|
647
|
-
}
|
|
648
|
-
return (value) => {
|
|
649
|
-
if (typeof value !== 'number') {
|
|
650
|
-
valueMustBeNumber('div');
|
|
651
|
-
}
|
|
652
|
-
return value / Number(opt);
|
|
653
|
-
};
|
|
654
|
-
};
|
|
655
|
-
/**
|
|
656
|
-
* Modulo filter - returns remainder of division.
|
|
657
|
-
*
|
|
658
|
-
* @param options - Array with divisor number as first element
|
|
659
|
-
* @returns Filter function that returns remainder
|
|
660
|
-
*/
|
|
661
|
-
const mod = (options) => {
|
|
662
|
-
const opt = options?.[0] ?? optionsRequired('mod');
|
|
663
|
-
if (!validateNumberString(opt)) {
|
|
664
|
-
optionMustBeNumber('mod');
|
|
665
|
-
}
|
|
666
|
-
return (value) => {
|
|
667
|
-
if (typeof value !== 'number') {
|
|
668
|
-
valueMustBeNumber('mod');
|
|
669
|
-
}
|
|
670
|
-
return value % Number(opt);
|
|
671
|
-
};
|
|
672
|
-
};
|
|
673
|
-
/**
|
|
674
|
-
* Absolute value filter - returns the magnitude of a number.
|
|
675
|
-
*
|
|
676
|
-
* @param options - Unused
|
|
677
|
-
* @returns Filter function that returns the absolute value
|
|
678
|
-
*/
|
|
679
|
-
const abs = (_options) => {
|
|
680
|
-
return (value) => {
|
|
681
|
-
if (typeof value !== 'number') {
|
|
682
|
-
valueMustBeNumber('abs');
|
|
683
|
-
}
|
|
684
|
-
return Math.abs(value);
|
|
685
|
-
};
|
|
686
|
-
};
|
|
687
|
-
/**
|
|
688
|
-
* Clamp filter - constrains a number to the inclusive range [min, max].
|
|
689
|
-
*
|
|
690
|
-
* Saturating conversion in the same family as round/floor/ceil, so it stays on
|
|
691
|
-
* the wire rather than in state. Pairs with `unit` for style bindings:
|
|
692
|
-
* `style.width: ratio|clamp(0,1)|percent(0)`.
|
|
693
|
-
*
|
|
694
|
-
* @param options - Array with minimum as first element and maximum as second (both required)
|
|
695
|
-
* @returns Filter function that returns the clamped number
|
|
696
|
-
*/
|
|
697
|
-
const clamp = (options) => {
|
|
698
|
-
const opt1 = options?.[0] ?? optionsRequired('clamp');
|
|
699
|
-
if (!validateNumberString(opt1)) {
|
|
700
|
-
optionMustBeNumber('clamp');
|
|
701
|
-
}
|
|
702
|
-
const opt2 = options?.[1] ?? optionsRequired('clamp');
|
|
703
|
-
if (!validateNumberString(opt2)) {
|
|
704
|
-
optionMustBeNumber('clamp');
|
|
705
|
-
}
|
|
706
|
-
const min = Number(opt1);
|
|
707
|
-
const max = Number(opt2);
|
|
708
|
-
return (value) => {
|
|
709
|
-
if (typeof value !== 'number') {
|
|
710
|
-
valueMustBeNumber('clamp');
|
|
711
|
-
}
|
|
712
|
-
return Math.min(Math.max(value, min), max);
|
|
713
|
-
};
|
|
714
|
-
};
|
|
715
|
-
/**
|
|
716
|
-
* Fixed decimal filter - formats number to fixed decimal places.
|
|
717
|
-
*
|
|
718
|
-
* @param options - Array with decimal places as first element (default: 0)
|
|
719
|
-
* @returns Filter function that returns formatted string
|
|
720
|
-
*/
|
|
721
|
-
const fix = (options) => {
|
|
722
|
-
const opt = options?.[0] ?? "0";
|
|
723
|
-
if (!validateNumberString(opt)) {
|
|
724
|
-
optionMustBeNumber('fix');
|
|
725
|
-
}
|
|
726
|
-
return (value) => {
|
|
727
|
-
if (typeof value !== 'number') {
|
|
728
|
-
valueMustBeNumber('fix');
|
|
729
|
-
}
|
|
730
|
-
return value.toFixed(Number(opt));
|
|
731
|
-
};
|
|
732
|
-
};
|
|
733
|
-
/**
|
|
734
|
-
* Locale number filter - formats number according to locale.
|
|
735
|
-
*
|
|
736
|
-
* ロケール依存フィルタ(`locale` / `date` / `time` / `datetime`)は
|
|
737
|
-
* **明示引数だけを構築時に確定し、既定の `config.locale` は適用のたびに読む**。
|
|
738
|
-
*
|
|
739
|
-
* 以前は `options?.[0] ?? config.locale` を返り値の関数の**外**で解決していた。
|
|
740
|
-
* フィルタ関数はバインド構築時に一度だけ作られるので、これはロケールを
|
|
741
|
-
* クロージャに焼き込むことを意味する。`config.locale` の確定がバインド構築より
|
|
742
|
-
* 遅れると、それ以降どう直しても「同じページの中で日付だけ既定ロケール」が
|
|
743
|
-
* 永続し、しかも `config.locale` は依存グラフに載らないので再描画で回復もしない。
|
|
744
|
-
* 症状(日付だけ英語)は原因(起動順序)から遠く、追いにくい。
|
|
745
|
-
*
|
|
746
|
-
* 適用のたびに読めば、少なくとも**再適用されたバインドは回復する**。ロケールは
|
|
747
|
-
* 起動時に確定する前提(docs/i18n-design.md D1)なので通常この差は現れず、
|
|
748
|
-
* これは順序事故から復帰できるようにするための保険である。
|
|
749
|
-
*
|
|
750
|
-
* 明示引数(`|date(ja-JP)`)は構築時に固定でよい — バインド式の一部であり、
|
|
751
|
-
* 実行中に変わらない。
|
|
752
|
-
*
|
|
753
|
-
* @param options - Array with locale string as first element (default: config.locale)
|
|
754
|
-
* @returns Filter function that returns localized number string
|
|
755
|
-
*/
|
|
756
|
-
const locale = (options) => {
|
|
757
|
-
const explicit = options?.[0];
|
|
758
|
-
return (value) => {
|
|
759
|
-
if (typeof value !== 'number') {
|
|
760
|
-
valueMustBeNumber('locale');
|
|
761
|
-
}
|
|
762
|
-
return value.toLocaleString(explicit ?? config.locale);
|
|
763
|
-
};
|
|
764
|
-
};
|
|
765
|
-
/**
|
|
766
|
-
* Uppercase filter - converts string to uppercase.
|
|
767
|
-
*
|
|
768
|
-
* @param options - Unused
|
|
769
|
-
* @returns Filter function that returns uppercase string
|
|
770
|
-
*/
|
|
771
|
-
const uc = (_options) => {
|
|
772
|
-
return (value) => {
|
|
773
|
-
return String(value).toUpperCase();
|
|
774
|
-
};
|
|
775
|
-
};
|
|
776
|
-
/**
|
|
777
|
-
* Lowercase filter - converts string to lowercase.
|
|
778
|
-
*
|
|
779
|
-
* @param options - Unused
|
|
780
|
-
* @returns Filter function that returns lowercase string
|
|
781
|
-
*/
|
|
782
|
-
const lc = (_options) => {
|
|
783
|
-
return (value) => {
|
|
784
|
-
return String(value).toLowerCase();
|
|
785
|
-
};
|
|
786
|
-
};
|
|
787
|
-
/**
|
|
788
|
-
* Capitalize filter - capitalizes first character of string.
|
|
789
|
-
*
|
|
790
|
-
* @param options - Unused
|
|
791
|
-
* @returns Filter function that returns capitalized string
|
|
792
|
-
*/
|
|
793
|
-
const cap = (_options) => {
|
|
794
|
-
return (value) => {
|
|
795
|
-
const v = String(value);
|
|
796
|
-
if (v.length === 0) {
|
|
797
|
-
return v;
|
|
798
|
-
}
|
|
799
|
-
if (v.length === 1) {
|
|
800
|
-
return v.toUpperCase();
|
|
801
|
-
}
|
|
802
|
-
return v.charAt(0).toUpperCase() + v.slice(1);
|
|
803
|
-
};
|
|
804
|
-
};
|
|
805
|
-
/**
|
|
806
|
-
* Trim filter - removes whitespace from both ends of string.
|
|
807
|
-
*
|
|
808
|
-
* @param options - Unused
|
|
809
|
-
* @returns Filter function that returns trimmed string
|
|
810
|
-
*/
|
|
811
|
-
const trim = (_options) => {
|
|
812
|
-
return (value) => {
|
|
813
|
-
return String(value).trim();
|
|
814
|
-
};
|
|
815
|
-
};
|
|
816
|
-
/**
|
|
817
|
-
* Slice filter - extracts portion of string from specified index.
|
|
818
|
-
*
|
|
819
|
-
* @param options - Array with start index and optional end index
|
|
820
|
-
* @returns Filter function that returns sliced string
|
|
821
|
-
*/
|
|
822
|
-
const slice = (options) => {
|
|
823
|
-
const numberedOpts = [];
|
|
824
|
-
const opt1 = options?.[0] ?? optionsRequired('slice');
|
|
825
|
-
if (!validateNumberString(opt1)) {
|
|
826
|
-
optionMustBeNumber('slice');
|
|
827
|
-
}
|
|
828
|
-
numberedOpts.push(Number(opt1));
|
|
829
|
-
const opt2 = options?.[1];
|
|
830
|
-
if (typeof opt2 !== 'undefined') {
|
|
831
|
-
if (!validateNumberString(opt2)) {
|
|
832
|
-
optionMustBeNumber('slice');
|
|
833
|
-
}
|
|
834
|
-
numberedOpts.push(Number(opt2));
|
|
835
|
-
}
|
|
836
|
-
return (value) => {
|
|
837
|
-
return String(value).slice(...numberedOpts);
|
|
838
|
-
};
|
|
839
|
-
};
|
|
840
|
-
/**
|
|
841
|
-
* Substring filter - extracts substring from specified position and length.
|
|
842
|
-
*
|
|
843
|
-
* @param options - Array with start index and length
|
|
844
|
-
* @returns Filter function that returns substring
|
|
845
|
-
*/
|
|
846
|
-
const substr = (options) => {
|
|
847
|
-
const opt1 = options?.[0] ?? optionsRequired('substr');
|
|
848
|
-
if (!validateNumberString(opt1)) {
|
|
849
|
-
optionMustBeNumber('substr');
|
|
850
|
-
}
|
|
851
|
-
const opt2 = options?.[1] ?? optionsRequired('substr');
|
|
852
|
-
if (!validateNumberString(opt2)) {
|
|
853
|
-
optionMustBeNumber('substr');
|
|
854
|
-
}
|
|
855
|
-
return (value) => {
|
|
856
|
-
return String(value).substr(Number(opt1), Number(opt2));
|
|
857
|
-
};
|
|
858
|
-
};
|
|
859
|
-
/**
|
|
860
|
-
* Pad filter - pads string to specified length from start.
|
|
861
|
-
*
|
|
862
|
-
* @param options - Array with target length and pad string (default: '0')
|
|
863
|
-
* @returns Filter function that returns padded string
|
|
864
|
-
*/
|
|
865
|
-
const pad = (options) => {
|
|
866
|
-
const opt1 = options?.[0] ?? optionsRequired('pad');
|
|
867
|
-
if (!validateNumberString(opt1)) {
|
|
868
|
-
optionMustBeNumber('pad');
|
|
869
|
-
}
|
|
870
|
-
const opt2 = options?.[1] ?? '0';
|
|
871
|
-
return (value) => {
|
|
872
|
-
return String(value).padStart(Number(opt1), opt2);
|
|
873
|
-
};
|
|
874
|
-
};
|
|
875
|
-
/**
|
|
876
|
-
* Repeat filter - repeats string specified number of times.
|
|
877
|
-
*
|
|
878
|
-
* @param options - Array with repeat count as first element
|
|
879
|
-
* @returns Filter function that returns repeated string
|
|
880
|
-
*/
|
|
881
|
-
const rep = (options) => {
|
|
882
|
-
const opt = options?.[0] ?? optionsRequired('rep');
|
|
883
|
-
if (!validateNumberString(opt)) {
|
|
884
|
-
optionMustBeNumber('rep');
|
|
885
|
-
}
|
|
886
|
-
return (value) => {
|
|
887
|
-
return String(value).repeat(Number(opt));
|
|
888
|
-
};
|
|
889
|
-
};
|
|
890
|
-
/**
|
|
891
|
-
* Reverse filter - reverses character order in string.
|
|
892
|
-
*
|
|
893
|
-
* @param options - Unused
|
|
894
|
-
* @returns Filter function that returns reversed string
|
|
895
|
-
*/
|
|
896
|
-
const rev = (_options) => {
|
|
897
|
-
return (value) => {
|
|
898
|
-
return String(value).split('').reverse().join('');
|
|
899
|
-
};
|
|
900
|
-
};
|
|
901
|
-
/**
|
|
902
|
-
* Integer filter - parses value to integer.
|
|
903
|
-
*
|
|
904
|
-
* @param options - Unused
|
|
905
|
-
* @returns Filter function that returns integer
|
|
906
|
-
*/
|
|
907
|
-
const int = (_options) => {
|
|
908
|
-
return (value) => {
|
|
909
|
-
return parseInt(String(value), 10);
|
|
910
|
-
};
|
|
911
|
-
};
|
|
912
|
-
/**
|
|
913
|
-
* Float filter - parses value to floating point number.
|
|
914
|
-
*
|
|
915
|
-
* @param options - Unused
|
|
916
|
-
* @returns Filter function that returns float
|
|
917
|
-
*/
|
|
918
|
-
const float = (_options) => {
|
|
919
|
-
return (value) => {
|
|
920
|
-
return parseFloat(String(value));
|
|
921
|
-
};
|
|
922
|
-
};
|
|
923
|
-
/**
|
|
924
|
-
* Round filter - rounds number to specified decimal places.
|
|
925
|
-
*
|
|
926
|
-
* @param options - Array with decimal places as first element (default: 0)
|
|
927
|
-
* @returns Filter function that returns rounded number
|
|
928
|
-
*/
|
|
929
|
-
const round = (options) => {
|
|
930
|
-
const opt = options?.[0] ?? '0';
|
|
931
|
-
if (!validateNumberString(opt)) {
|
|
932
|
-
optionMustBeNumber('round');
|
|
933
|
-
}
|
|
934
|
-
return (value) => {
|
|
935
|
-
if (typeof value !== 'number') {
|
|
936
|
-
valueMustBeNumber('round');
|
|
937
|
-
}
|
|
938
|
-
const optValue = Math.pow(10, Number(opt));
|
|
939
|
-
return Math.round(value * optValue) / optValue;
|
|
940
|
-
};
|
|
941
|
-
};
|
|
942
|
-
/**
|
|
943
|
-
* Floor filter - rounds number down to specified decimal places.
|
|
944
|
-
*
|
|
945
|
-
* @param options - Array with decimal places as first element (default: 0)
|
|
946
|
-
* @returns Filter function that returns floored number
|
|
947
|
-
*/
|
|
948
|
-
const floor = (options) => {
|
|
949
|
-
const opt = options?.[0] ?? '0';
|
|
950
|
-
if (!validateNumberString(opt)) {
|
|
951
|
-
optionMustBeNumber('floor');
|
|
952
|
-
}
|
|
953
|
-
return (value) => {
|
|
954
|
-
if (typeof value !== 'number') {
|
|
955
|
-
valueMustBeNumber('floor');
|
|
956
|
-
}
|
|
957
|
-
const optValue = Math.pow(10, Number(opt));
|
|
958
|
-
return Math.floor(value * optValue) / optValue;
|
|
959
|
-
};
|
|
960
|
-
};
|
|
961
|
-
/**
|
|
962
|
-
* Ceiling filter - rounds number up to specified decimal places.
|
|
963
|
-
*
|
|
964
|
-
* @param options - Array with decimal places as first element (default: 0)
|
|
965
|
-
* @returns Filter function that returns ceiled number
|
|
966
|
-
*/
|
|
967
|
-
const ceil = (options) => {
|
|
968
|
-
const opt = options?.[0] ?? '0';
|
|
969
|
-
if (!validateNumberString(opt)) {
|
|
970
|
-
optionMustBeNumber('ceil');
|
|
971
|
-
}
|
|
972
|
-
return (value) => {
|
|
973
|
-
if (typeof value !== 'number') {
|
|
974
|
-
valueMustBeNumber('ceil');
|
|
975
|
-
}
|
|
976
|
-
const optValue = Math.pow(10, Number(opt));
|
|
977
|
-
return Math.ceil(value * optValue) / optValue;
|
|
978
|
-
};
|
|
979
|
-
};
|
|
980
|
-
/**
|
|
981
|
-
* Percent filter - formats number as percentage string.
|
|
982
|
-
*
|
|
983
|
-
* @param options - Array with decimal places as first element (default: 0)
|
|
984
|
-
* @returns Filter function that returns percentage string with '%'
|
|
985
|
-
*/
|
|
986
|
-
const percent = (options) => {
|
|
987
|
-
const opt = options?.[0] ?? '0';
|
|
988
|
-
if (!validateNumberString(opt)) {
|
|
989
|
-
optionMustBeNumber('percent');
|
|
990
|
-
}
|
|
991
|
-
return (value) => {
|
|
992
|
-
if (typeof value !== 'number') {
|
|
993
|
-
valueMustBeNumber('percent');
|
|
994
|
-
}
|
|
995
|
-
return `${(value * 100).toFixed(Number(opt))}%`;
|
|
996
|
-
};
|
|
997
|
-
};
|
|
998
|
-
/**
|
|
999
|
-
* Unit filter - appends a CSS unit (or any suffix) to the value.
|
|
1000
|
-
*
|
|
1001
|
-
* A number alone does nothing in CSS, so without this the unit has to be built in
|
|
1002
|
-
* state — which drags presentation into the source of truth, and in the worst case
|
|
1003
|
-
* forces a whole derived array just to carry `"42%"` strings.
|
|
1004
|
-
* `style.height: samples.*.cpu|clamp(0,100)|fix(0)|unit(%)` keeps it on the wire.
|
|
1005
|
-
*
|
|
1006
|
-
* Accepts strings as well as numbers **on purpose**: the useful chains run through
|
|
1007
|
-
* `fix` / `percent`, which already return strings. Rejecting non-numbers here would
|
|
1008
|
-
* break exactly the combination this filter exists for.
|
|
1009
|
-
*
|
|
1010
|
-
* `null` / `undefined` pass through untouched rather than becoming `"undefinedpx"`,
|
|
1011
|
-
* so the binding layer's "undefined skips the write, null clears" semantics survive.
|
|
1012
|
-
*
|
|
1013
|
-
* @param options - Array with the unit/suffix as first element (required)
|
|
1014
|
-
* @returns Filter function that returns the value with the unit appended
|
|
1015
|
-
*/
|
|
1016
|
-
const unit = (options) => {
|
|
1017
|
-
const opt = options?.[0] ?? optionsRequired('unit');
|
|
1018
|
-
return (value) => {
|
|
1019
|
-
if (value === null || typeof value === 'undefined') {
|
|
1020
|
-
return value;
|
|
1021
|
-
}
|
|
1022
|
-
return String(value) + opt;
|
|
1023
|
-
};
|
|
1024
|
-
};
|
|
1025
|
-
/**
|
|
1026
|
-
* Join filter - joins array elements into a string.
|
|
1027
|
-
*
|
|
1028
|
-
* The default separator is `", "` rather than `","`: a bare comma is what `String()`
|
|
1029
|
-
* already produces without any filter, so defaulting to it would make `|join` a no-op.
|
|
1030
|
-
*
|
|
1031
|
-
* @param options - Array with separator as first element (default: ', ')
|
|
1032
|
-
* @returns Filter function that returns the joined string
|
|
1033
|
-
*/
|
|
1034
|
-
const join = (options) => {
|
|
1035
|
-
const opt = options?.[0] ?? ', ';
|
|
1036
|
-
return (value) => {
|
|
1037
|
-
if (!Array.isArray(value)) {
|
|
1038
|
-
valueMustBeArray('join');
|
|
1039
|
-
}
|
|
1040
|
-
return value.join(opt);
|
|
1041
|
-
};
|
|
1042
|
-
};
|
|
1043
205
|
/**
|
|
1044
|
-
*
|
|
206
|
+
* core/filterRegistry.ts — フィルタ実関数の登録簿(設計案 §4、要件 D16)。
|
|
1045
207
|
*
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
*
|
|
208
|
+
* 文法(`path|filter(args)` の解析)は core に残り、**実関数は登録簿から束縛計画の段で引く**。
|
|
209
|
+
* 解析の段は名前と引数しか作らない(`bindTextParser/parseFilters.ts`)ので、パーサだけを使う
|
|
210
|
+
* tooling(`@wcstack/state/parser`)はフィルタの実装を 1 バイトも引き込まない。
|
|
1049
211
|
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
212
|
+
* 書式フィルタ群(`uc` / `date` / `round` …)は `features/formats` が install で登録する。
|
|
213
|
+
* core が自前で持つのは、エンジン自身が差し込む `not` だけ(`if` / `else` の反転 —
|
|
214
|
+
* structural/notFilter.ts)。未知のフィルタは束縛計画の段で名指しで落ちる(従来は解析時)。
|
|
1052
215
|
*/
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
const maxLength = Number(opt1);
|
|
1059
|
-
const suffix = options?.[1] ?? '…';
|
|
1060
|
-
return (value) => {
|
|
1061
|
-
const v = String(value);
|
|
1062
|
-
if (v.length <= maxLength) {
|
|
1063
|
-
return v;
|
|
1064
|
-
}
|
|
1065
|
-
return v.slice(0, maxLength) + suffix;
|
|
1066
|
-
};
|
|
1067
|
-
};
|
|
1068
|
-
/**
|
|
1069
|
-
* Date filter - formats Date object as localized date string.
|
|
1070
|
-
*
|
|
1071
|
-
* @param options - Array with locale string as first element (default: config.locale)
|
|
1072
|
-
* @returns Filter function that returns date string
|
|
1073
|
-
*/
|
|
1074
|
-
const date = (options) => {
|
|
1075
|
-
// 既定ロケールは適用のたびに読む(`locale` フィルタの注記を参照)
|
|
1076
|
-
const explicit = options?.[0];
|
|
1077
|
-
return (value) => {
|
|
1078
|
-
if (!(value instanceof Date)) {
|
|
1079
|
-
valueMustBeDate('date');
|
|
1080
|
-
}
|
|
1081
|
-
return value.toLocaleDateString(explicit ?? config.locale);
|
|
1082
|
-
};
|
|
1083
|
-
};
|
|
1084
|
-
/**
|
|
1085
|
-
* Time filter - formats Date object as localized time string.
|
|
1086
|
-
*
|
|
1087
|
-
* @param options - Array with locale string as first element (default: config.locale)
|
|
1088
|
-
* @returns Filter function that returns time string
|
|
1089
|
-
*/
|
|
1090
|
-
const time = (options) => {
|
|
1091
|
-
// 既定ロケールは適用のたびに読む(`locale` フィルタの注記を参照)
|
|
1092
|
-
const explicit = options?.[0];
|
|
1093
|
-
return (value) => {
|
|
1094
|
-
if (!(value instanceof Date)) {
|
|
1095
|
-
valueMustBeDate('time');
|
|
1096
|
-
}
|
|
1097
|
-
return value.toLocaleTimeString(explicit ?? config.locale);
|
|
1098
|
-
};
|
|
1099
|
-
};
|
|
1100
|
-
/**
|
|
1101
|
-
* DateTime filter - formats Date object as localized date and time string.
|
|
1102
|
-
*
|
|
1103
|
-
* @param options - Array with locale string as first element (default: config.locale)
|
|
1104
|
-
* @returns Filter function that returns datetime string
|
|
1105
|
-
*/
|
|
1106
|
-
const datetime = (options) => {
|
|
1107
|
-
// 既定ロケールは適用のたびに読む(`locale` フィルタの注記を参照)
|
|
1108
|
-
const explicit = options?.[0];
|
|
1109
|
-
return (value) => {
|
|
1110
|
-
if (!(value instanceof Date)) {
|
|
1111
|
-
valueMustBeDate('datetime');
|
|
1112
|
-
}
|
|
1113
|
-
return value.toLocaleString(explicit ?? config.locale);
|
|
1114
|
-
};
|
|
1115
|
-
};
|
|
1116
|
-
/**
|
|
1117
|
-
* Year-Month-Day filter - formats Date object as YYYY-MM-DD string.
|
|
1118
|
-
*
|
|
1119
|
-
* @param options - Array with separator string as first element (default: '-')
|
|
1120
|
-
* @returns Filter function that returns formatted date string
|
|
1121
|
-
*/
|
|
1122
|
-
const ymd = (options) => {
|
|
1123
|
-
const opt = options?.[0] ?? '-';
|
|
1124
|
-
return (value) => {
|
|
1125
|
-
if (!(value instanceof Date)) {
|
|
1126
|
-
valueMustBeDate('ymd');
|
|
1127
|
-
}
|
|
1128
|
-
const year = value.getFullYear().toString();
|
|
1129
|
-
const month = (value.getMonth() + 1).toString().padStart(2, '0');
|
|
1130
|
-
const day = value.getDate().toString().padStart(2, '0');
|
|
1131
|
-
return `${year}${opt}${month}${opt}${day}`;
|
|
1132
|
-
};
|
|
1133
|
-
};
|
|
1134
|
-
/**
|
|
1135
|
-
* Hour-Minute-Second filter - formats Date object as HH:MM:SS string.
|
|
1136
|
-
*
|
|
1137
|
-
* The counterpart of `ymd`: a fixed, zero-padded, locale-independent rendering with a
|
|
1138
|
-
* configurable separator, for when `time` (locale-formatted) is not stable enough.
|
|
1139
|
-
*
|
|
1140
|
-
* @param options - Array with separator string as first element (default: ':')
|
|
1141
|
-
* @returns Filter function that returns formatted time string
|
|
1142
|
-
*/
|
|
1143
|
-
const hms = (options) => {
|
|
1144
|
-
const opt = options?.[0] ?? ':';
|
|
1145
|
-
return (value) => {
|
|
1146
|
-
if (!(value instanceof Date)) {
|
|
1147
|
-
valueMustBeDate('hms');
|
|
1148
|
-
}
|
|
1149
|
-
const hours = value.getHours().toString().padStart(2, '0');
|
|
1150
|
-
const minutes = value.getMinutes().toString().padStart(2, '0');
|
|
1151
|
-
const seconds = value.getSeconds().toString().padStart(2, '0');
|
|
1152
|
-
return `${hours}${opt}${minutes}${opt}${seconds}`;
|
|
1153
|
-
};
|
|
1154
|
-
};
|
|
1155
|
-
/** 3.0 は truthy / falsy / defaults を JavaScript の真偽判定に揃える(要件 B10)— 0n の差を予告する */
|
|
1156
|
-
function warnBigIntZero(fnName, value) {
|
|
1157
|
-
if (value === 0n) {
|
|
1158
|
-
warnV3Migration(`"${fnName}" got 0n: 3.0 treats it as falsy.`);
|
|
1159
|
-
}
|
|
216
|
+
/** 名前 + 引数 + 入出力ごとに解決済みの実関数(解決は 1 回だけ) */
|
|
217
|
+
const resolvedByKey = new Map();
|
|
218
|
+
/** 解決済みの答えを捨てる(tooling: `@wcstack/state/parser` の clearParserCaches) */
|
|
219
|
+
function clearFilterResolutionCache() {
|
|
220
|
+
resolvedByKey.clear();
|
|
1160
221
|
}
|
|
1161
|
-
/**
|
|
1162
|
-
* Falsy filter - checks if value is falsy.
|
|
1163
|
-
*
|
|
1164
|
-
* @param options - Unused
|
|
1165
|
-
* @returns Filter function that returns true for false/null/undefined/0/''/NaN
|
|
1166
|
-
*/
|
|
1167
|
-
const falsy = (_options) => {
|
|
1168
|
-
return (value) => {
|
|
1169
|
-
warnBigIntZero('falsy', value);
|
|
1170
|
-
return value === false || value === null || value === undefined || value === 0 || value === '' || Number.isNaN(value);
|
|
1171
|
-
};
|
|
1172
|
-
};
|
|
1173
|
-
/**
|
|
1174
|
-
* Truthy filter - checks if value is truthy.
|
|
1175
|
-
*
|
|
1176
|
-
* @param options - Unused
|
|
1177
|
-
* @returns Filter function that returns true for non-falsy values
|
|
1178
|
-
*/
|
|
1179
|
-
const truthy = (_options) => {
|
|
1180
|
-
return (value) => {
|
|
1181
|
-
warnBigIntZero('truthy', value);
|
|
1182
|
-
return value !== false && value !== null && value !== undefined && value !== 0 && value !== '' && !Number.isNaN(value);
|
|
1183
|
-
};
|
|
1184
|
-
};
|
|
1185
|
-
/**
|
|
1186
|
-
* Default filter - returns default value if input is falsy.
|
|
1187
|
-
*
|
|
1188
|
-
* @param options - Array with default value as first element
|
|
1189
|
-
* @returns Filter function that returns value or default
|
|
1190
|
-
*/
|
|
1191
|
-
const defaults = (options) => {
|
|
1192
|
-
const opt = options?.[0] ?? optionsRequired('defaults');
|
|
1193
|
-
return (value) => {
|
|
1194
|
-
warnBigIntZero('defaults', value);
|
|
1195
|
-
if (value === false || value === null || value === undefined || value === 0 || value === '' || Number.isNaN(value)) {
|
|
1196
|
-
return opt;
|
|
1197
|
-
}
|
|
1198
|
-
return value;
|
|
1199
|
-
};
|
|
1200
|
-
};
|
|
1201
|
-
/**
|
|
1202
|
-
* Boolean filter - converts value to boolean.
|
|
1203
|
-
*
|
|
1204
|
-
* @param options - Unused
|
|
1205
|
-
* @returns Filter function that returns boolean
|
|
1206
|
-
*/
|
|
1207
|
-
const boolean = (_options) => {
|
|
1208
|
-
return (value) => {
|
|
1209
|
-
return Boolean(value);
|
|
1210
|
-
};
|
|
1211
|
-
};
|
|
1212
|
-
/**
|
|
1213
|
-
* Number filter - converts value to number.
|
|
1214
|
-
*
|
|
1215
|
-
* @param options - Unused
|
|
1216
|
-
* @returns Filter function that returns number
|
|
1217
|
-
*/
|
|
1218
|
-
const number = (_options) => {
|
|
1219
|
-
return (value) => {
|
|
1220
|
-
return Number(value);
|
|
1221
|
-
};
|
|
1222
|
-
};
|
|
1223
|
-
/**
|
|
1224
|
-
* String filter - converts value to string.
|
|
1225
|
-
*
|
|
1226
|
-
* @param options - Unused
|
|
1227
|
-
* @returns Filter function that returns string
|
|
1228
|
-
*/
|
|
1229
|
-
const string = (_options) => {
|
|
1230
|
-
return (value) => {
|
|
1231
|
-
return String(value);
|
|
1232
|
-
};
|
|
1233
|
-
};
|
|
1234
|
-
/**
|
|
1235
|
-
* Null filter - converts empty string to null.
|
|
1236
|
-
*
|
|
1237
|
-
* @param options - Unused
|
|
1238
|
-
* @returns Filter function that returns null for empty string, otherwise original value
|
|
1239
|
-
*/
|
|
1240
|
-
const _null = (_options) => {
|
|
1241
|
-
return (value) => {
|
|
1242
|
-
return (value === "") ? null : value;
|
|
1243
|
-
};
|
|
1244
|
-
};
|
|
1245
|
-
const builtinFilters = {
|
|
1246
|
-
"eq": eq,
|
|
1247
|
-
"ne": ne,
|
|
1248
|
-
"not": not,
|
|
1249
|
-
"lt": lt,
|
|
1250
|
-
"le": le,
|
|
1251
|
-
"gt": gt,
|
|
1252
|
-
"ge": ge,
|
|
1253
|
-
"inc": inc,
|
|
1254
|
-
"dec": dec,
|
|
1255
|
-
"mul": mul,
|
|
1256
|
-
"div": div,
|
|
1257
|
-
"mod": mod,
|
|
1258
|
-
"abs": abs,
|
|
1259
|
-
"clamp": clamp,
|
|
1260
|
-
"fix": fix,
|
|
1261
|
-
"locale": locale,
|
|
1262
|
-
"uc": uc,
|
|
1263
|
-
"lc": lc,
|
|
1264
|
-
"cap": cap,
|
|
1265
|
-
"trim": trim,
|
|
1266
|
-
"slice": slice,
|
|
1267
|
-
"substr": substr,
|
|
1268
|
-
"pad": pad,
|
|
1269
|
-
"rep": rep,
|
|
1270
|
-
"rev": rev,
|
|
1271
|
-
"truncate": truncate,
|
|
1272
|
-
"join": join,
|
|
1273
|
-
"int": int,
|
|
1274
|
-
"float": float,
|
|
1275
|
-
"round": round,
|
|
1276
|
-
"floor": floor,
|
|
1277
|
-
"ceil": ceil,
|
|
1278
|
-
"percent": percent,
|
|
1279
|
-
"unit": unit,
|
|
1280
|
-
"date": date,
|
|
1281
|
-
"time": time,
|
|
1282
|
-
"datetime": datetime,
|
|
1283
|
-
"ymd": ymd,
|
|
1284
|
-
"hms": hms,
|
|
1285
|
-
"falsy": falsy,
|
|
1286
|
-
"truthy": truthy,
|
|
1287
|
-
"defaults": defaults,
|
|
1288
|
-
"boolean": boolean,
|
|
1289
|
-
"number": number,
|
|
1290
|
-
"string": string,
|
|
1291
|
-
"null": _null,
|
|
1292
|
-
};
|
|
1293
|
-
const outputBuiltinFilters = builtinFilters;
|
|
1294
|
-
const inputBuiltinFilters = builtinFilters;
|
|
1295
|
-
const builtinFiltersByFilterIOType = {
|
|
1296
|
-
"input": inputBuiltinFilters,
|
|
1297
|
-
"output": outputBuiltinFilters,
|
|
1298
|
-
};
|
|
1299
|
-
/**
|
|
1300
|
-
* Retrieves built-in filter function by name and options.
|
|
1301
|
-
*
|
|
1302
|
-
* @param name - Filter name
|
|
1303
|
-
* @param options - Array of option strings
|
|
1304
|
-
* @returns Function that takes FilterWithOptions and returns filter function
|
|
1305
|
-
*/
|
|
1306
|
-
const builtinFilterFn = (name, options) => (filters) => {
|
|
1307
|
-
const filter = filters[name];
|
|
1308
|
-
if (!filter) {
|
|
1309
|
-
// lint の wcs/filter-unknown と同じ語彙・同じ did-you-mean 規準(三面同語彙)。
|
|
1310
|
-
raiseError(`[wcs/filter-unknown] filter not found: ${name}.${didYouMean(name, Object.keys(filters))}${LINT_HINT}`);
|
|
1311
|
-
}
|
|
1312
|
-
return filter(options);
|
|
1313
|
-
};
|
|
1314
222
|
|
|
1315
223
|
/**
|
|
1316
224
|
* フィルタ引数リストのパース。`filter(a, b)` の `a, b` 部分を受け取る。
|
|
@@ -1336,15 +244,32 @@ function finalizeArg(text, firstQuoteStart, lastQuoteEnd) {
|
|
|
1336
244
|
}
|
|
1337
245
|
return text.slice(start, end);
|
|
1338
246
|
}
|
|
1339
|
-
|
|
247
|
+
/** 引用符の無い引数の型(要件 B9): true / false / null / 数値は型付き、それ以外は文字列 */
|
|
248
|
+
const NUMBER_LITERAL = /^[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?$/;
|
|
249
|
+
function toLiteral(text, quoted) {
|
|
250
|
+
if (quoted)
|
|
251
|
+
return text;
|
|
252
|
+
if (text === "true")
|
|
253
|
+
return true;
|
|
254
|
+
if (text === "false")
|
|
255
|
+
return false;
|
|
256
|
+
if (text === "null")
|
|
257
|
+
return null;
|
|
258
|
+
return NUMBER_LITERAL.test(text) ? Number(text) : text;
|
|
259
|
+
}
|
|
260
|
+
/** 引数の原文と、その型付きの値(要件 B9)を一緒に返す。原文は引用符を外したもの */
|
|
261
|
+
function parseFilterArgsWithLiterals(argsText) {
|
|
1340
262
|
const args = [];
|
|
263
|
+
const literals = [];
|
|
1341
264
|
let current = '';
|
|
1342
265
|
let inQuote = null;
|
|
1343
266
|
let hasQuote = false;
|
|
1344
267
|
let firstQuoteStart = -1;
|
|
1345
268
|
let lastQuoteEnd = -1;
|
|
1346
269
|
const flush = () => {
|
|
1347
|
-
|
|
270
|
+
const arg = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
|
|
271
|
+
args.push(arg);
|
|
272
|
+
literals.push(toLiteral(arg, hasQuote));
|
|
1348
273
|
current = '';
|
|
1349
274
|
hasQuote = false;
|
|
1350
275
|
firstQuoteStart = -1;
|
|
@@ -1375,22 +300,31 @@ function parseFilterArgs(argsText) {
|
|
|
1375
300
|
current += char;
|
|
1376
301
|
}
|
|
1377
302
|
}
|
|
303
|
+
if (inQuote !== null) {
|
|
304
|
+
// 閉じていない引用符は受理しない(要件 B2)。以前は黙って閉じたことにしていた
|
|
305
|
+
raiseError(`[wcs/binding-syntax] unterminated ${inQuote} quote in the filter arguments "(${argsText})". Close the quote.${LINT_HINT}`);
|
|
306
|
+
}
|
|
1378
307
|
const last = finalizeArg(current, firstQuoteStart, lastQuoteEnd);
|
|
1379
308
|
if (last || hasQuote) {
|
|
1380
309
|
args.push(last);
|
|
310
|
+
literals.push(toLiteral(last, hasQuote));
|
|
1381
311
|
}
|
|
1382
|
-
return args;
|
|
312
|
+
return { args, literals };
|
|
1383
313
|
}
|
|
1384
314
|
|
|
1385
|
-
const filterFnByKey = new Map();
|
|
1386
315
|
/** tooling 専用(parser.ts の clearParserCaches からのみ呼ぶ)。 */
|
|
1387
316
|
function clearFilterFnCacheForTooling() {
|
|
1388
|
-
|
|
317
|
+
clearFilterResolutionCache();
|
|
1389
318
|
}
|
|
1390
319
|
// format: filterName(arg1,arg2) or filterName
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
320
|
+
/**
|
|
321
|
+
* 文法の段(要件 D16): 名前と引数だけを読む。**実関数は引かない** — 束縛計画の段で
|
|
322
|
+
* 登録簿から解決する(`core/filterRegistry.ts`・`bindings/getBindingInfos.ts`)。
|
|
323
|
+
* 未知のフィルタもここでは落とさない: パーサだけを使う tooling は実装を持たないので、
|
|
324
|
+
* 「知らない名前」を解析の段で判定できない。
|
|
325
|
+
*/
|
|
326
|
+
function parseFilters(filterTextList, _filterIOType) {
|
|
327
|
+
return filterTextList.map((filterText) => {
|
|
1394
328
|
const openParenIndex = filterText.indexOf('(');
|
|
1395
329
|
const closeParenIndex = filterText.lastIndexOf(')');
|
|
1396
330
|
// check parentheses
|
|
@@ -1400,42 +334,69 @@ function parseFilters(filterTextList, filterIOType) {
|
|
|
1400
334
|
if (closeParenIndex !== -1 && openParenIndex === -1) {
|
|
1401
335
|
raiseError(`Invalid filter format: missing opening parenthesis in "${filterText}"`);
|
|
1402
336
|
}
|
|
337
|
+
const filterName = (openParenIndex === -1 ? filterText : filterText.substring(0, openParenIndex)).trim();
|
|
338
|
+
if (filterName.length === 0) {
|
|
339
|
+
// 空のフィルタ(`x|`・`x||y`・`x|(1)`)は文法の誤り。解析の段で名指しで落とす — 未知の
|
|
340
|
+
// フィルタとは別物で、実関数の解決(束縛計画の段)まで持ち越すと tooling の解析が素通りする
|
|
341
|
+
raiseError(`[wcs/binding-syntax] an empty filter in "${filterTextList.join("|")}" — remove the extra "|" or name the filter.${LINT_HINT}`);
|
|
342
|
+
}
|
|
1403
343
|
if (openParenIndex === -1) {
|
|
1404
344
|
// no arguments
|
|
1405
|
-
|
|
1406
|
-
const filterKey = `${filterName}():${filterIOType}`;
|
|
1407
|
-
let filterFn = filterFnByKey.get(filterKey);
|
|
1408
|
-
if (typeof filterFn === 'undefined') {
|
|
1409
|
-
filterFn = builtinFilterFn(filterName, [])(builtinFilters);
|
|
1410
|
-
filterFnByKey.set(filterKey, filterFn);
|
|
1411
|
-
}
|
|
1412
|
-
return {
|
|
1413
|
-
filterName: filterName,
|
|
1414
|
-
args: [],
|
|
1415
|
-
filterFn: filterFn,
|
|
1416
|
-
};
|
|
1417
|
-
}
|
|
1418
|
-
else {
|
|
1419
|
-
const argsText = filterText.substring(openParenIndex + 1, closeParenIndex);
|
|
1420
|
-
const filterName = filterText.substring(0, openParenIndex).trim();
|
|
1421
|
-
const args = parseFilterArgs(argsText);
|
|
1422
|
-
const filterKey = `${filterName}(${args.join(',')}):${filterIOType}`;
|
|
1423
|
-
let filterFn = filterFnByKey.get(filterKey);
|
|
1424
|
-
if (typeof filterFn === 'undefined') {
|
|
1425
|
-
filterFn = builtinFilterFn(filterName, args)(builtinFilters);
|
|
1426
|
-
filterFnByKey.set(filterKey, filterFn);
|
|
1427
|
-
}
|
|
1428
|
-
return {
|
|
1429
|
-
filterName,
|
|
1430
|
-
args,
|
|
1431
|
-
filterFn,
|
|
1432
|
-
};
|
|
345
|
+
return { filterName, args: [], literals: [] };
|
|
1433
346
|
}
|
|
347
|
+
const argsText = filterText.substring(openParenIndex + 1, closeParenIndex);
|
|
348
|
+
return { filterName, ...parseFilterArgsWithLiterals(argsText) };
|
|
1434
349
|
});
|
|
1435
|
-
return filters;
|
|
1436
350
|
}
|
|
1437
351
|
|
|
1438
352
|
const trimFn = (s) => s.trim();
|
|
353
|
+
const isQuote = (c) => c === "'" || c === '"';
|
|
354
|
+
/**
|
|
355
|
+
* `text` の中で、引用符(`'` / `"`)の外にある最初の `char` の位置。無ければ -1(要件 B1)。
|
|
356
|
+
* 閉じていない引用符はそのまま末尾まで続く扱い — 不正な引用符はフィルタ引数の段で名指しで落ちる。
|
|
357
|
+
*/
|
|
358
|
+
function indexOfOutsideQuotes(text, char) {
|
|
359
|
+
let quote = null;
|
|
360
|
+
for (let i = 0; i < text.length; i++) {
|
|
361
|
+
const c = text[i];
|
|
362
|
+
if (quote !== null) {
|
|
363
|
+
if (c === quote)
|
|
364
|
+
quote = null;
|
|
365
|
+
}
|
|
366
|
+
else if (isQuote(c)) {
|
|
367
|
+
quote = c;
|
|
368
|
+
}
|
|
369
|
+
else if (c === char) {
|
|
370
|
+
return i;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
return -1;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* `separator` で区切る。ただし引用符の中は区切らない(要件 B1): `join(';')` や `join('|')` の
|
|
377
|
+
* 区切り文字は引数であって、バインディングやフィルタの区切りではない。
|
|
378
|
+
*/
|
|
379
|
+
function splitOutsideQuotes(text, separator) {
|
|
380
|
+
const parts = [];
|
|
381
|
+
let quote = null;
|
|
382
|
+
let start = 0;
|
|
383
|
+
for (let i = 0; i < text.length; i++) {
|
|
384
|
+
const c = text[i];
|
|
385
|
+
if (quote !== null) {
|
|
386
|
+
if (c === quote)
|
|
387
|
+
quote = null;
|
|
388
|
+
}
|
|
389
|
+
else if (isQuote(c)) {
|
|
390
|
+
quote = c;
|
|
391
|
+
}
|
|
392
|
+
else if (c === separator) {
|
|
393
|
+
parts.push(text.slice(start, i));
|
|
394
|
+
start = i + 1;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
parts.push(text.slice(start));
|
|
398
|
+
return parts;
|
|
399
|
+
}
|
|
1439
400
|
|
|
1440
401
|
const cacheFilterInfos$1 = new Map();
|
|
1441
402
|
/** tooling 専用(parser.ts の clearParserCaches からのみ呼ぶ)。 */
|
|
@@ -1450,7 +411,7 @@ function clearPropPartCacheForTooling() {
|
|
|
1450
411
|
// 'class.className' for class names (e.g., class.active, class.hidden)
|
|
1451
412
|
// 'onclick', 'onchange' etc. for event listeners
|
|
1452
413
|
function parsePropPart(propPart) {
|
|
1453
|
-
const pos = propPart
|
|
414
|
+
const pos = indexOfOutsideQuotes(propPart, FILTER_SEPARATOR);
|
|
1454
415
|
let propText = '';
|
|
1455
416
|
let filterTexts = [];
|
|
1456
417
|
let filtersText = '';
|
|
@@ -1462,15 +423,20 @@ function parsePropPart(propPart) {
|
|
|
1462
423
|
filters = cacheFilterInfos$1.get(filtersText);
|
|
1463
424
|
}
|
|
1464
425
|
else {
|
|
1465
|
-
filterTexts = filtersText
|
|
1466
|
-
filters = parseFilters(filterTexts
|
|
426
|
+
filterTexts = splitOutsideQuotes(filtersText, FILTER_SEPARATOR).map(trimFn);
|
|
427
|
+
filters = parseFilters(filterTexts);
|
|
1467
428
|
cacheFilterInfos$1.set(filtersText, filters);
|
|
1468
429
|
}
|
|
1469
430
|
}
|
|
1470
431
|
else {
|
|
1471
432
|
propText = propPart.trim();
|
|
1472
433
|
}
|
|
1473
|
-
const
|
|
434
|
+
const modifierParts = propText.split(MODIFIER_SEPARATOR).map(trimFn);
|
|
435
|
+
if (modifierParts.length > 2) {
|
|
436
|
+
// 修飾子の並びは 1 つだけ(要件 B2)。`value#ro#wo` は以前 `ro` だけを残して黙って捨てていた
|
|
437
|
+
raiseError(`[wcs/binding-syntax] "${propText}": a binding takes one modifier list after a single "${MODIFIER_SEPARATOR}" — write "${modifierParts[0]}${MODIFIER_SEPARATOR}${modifierParts.slice(1).join(",")}".${LINT_HINT}`);
|
|
438
|
+
}
|
|
439
|
+
const [propName, propModifiersText] = modifierParts;
|
|
1474
440
|
const propSegments = propName.split(DELIMITER).map(trimFn);
|
|
1475
441
|
const propModifiers = propModifiersText
|
|
1476
442
|
? propModifiersText.split(',').map(trimFn)
|
|
@@ -1492,7 +458,8 @@ function clearStatePartCacheForTooling() {
|
|
|
1492
458
|
// statePath-format: path.to.property (e.g., user.name.first, users.*.name, users.0.name, not include @)
|
|
1493
459
|
// filters-format: filterName or filterName(arg1,arg2)
|
|
1494
460
|
function parseStatePart(statePart) {
|
|
1495
|
-
|
|
461
|
+
// 引用符の中の `|` はフィルタの区切りではない(要件 B1 — `join('|')`)
|
|
462
|
+
const pos = indexOfOutsideQuotes(statePart, FILTER_SEPARATOR);
|
|
1496
463
|
let stateAndPath = '';
|
|
1497
464
|
let filterTexts = [];
|
|
1498
465
|
let filtersText = '';
|
|
@@ -1504,8 +471,8 @@ function parseStatePart(statePart) {
|
|
|
1504
471
|
filters = cacheFilterInfos.get(filtersText);
|
|
1505
472
|
}
|
|
1506
473
|
else {
|
|
1507
|
-
filterTexts = filtersText
|
|
1508
|
-
filters = parseFilters(filterTexts
|
|
474
|
+
filterTexts = splitOutsideQuotes(filtersText, FILTER_SEPARATOR).map(trimFn);
|
|
475
|
+
filters = parseFilters(filterTexts);
|
|
1509
476
|
cacheFilterInfos.set(filtersText, filters);
|
|
1510
477
|
}
|
|
1511
478
|
}
|
|
@@ -1534,8 +501,17 @@ function parseStatePart(statePart) {
|
|
|
1534
501
|
// for: statePart only (single binding for loop rendering)
|
|
1535
502
|
// onclick: statePart, onchange: statePart etc. (event listeners)
|
|
1536
503
|
// ...: statePart (spread — expand wcBindable properties+inputs of target object)
|
|
504
|
+
/** 左辺に修飾子も入力フィルタも取らない束縛(構造ディレクティブと spread)— 付いていれば拒否する(要件 B4) */
|
|
505
|
+
const KEYWORDS_WITHOUT_MODIFIERS = new Set([ELSE_KEYWORD, 'if', 'elseif', 'for', SPREAD_PROP]);
|
|
506
|
+
/**
|
|
507
|
+
* `data-wcs` の値をバインディングごとに区切る(前後の空白は残す — tooling が位置を数えられるように)。
|
|
508
|
+
* 引用符の中の `;` は区切りではない(要件 B1 — `join(';')`)。ランタイムと tooling(`@wcstack/state/parser`)で共有する
|
|
509
|
+
*/
|
|
510
|
+
function splitBindTexts(bindText) {
|
|
511
|
+
return splitOutsideQuotes(bindText, BINDING_SEPARATOR);
|
|
512
|
+
}
|
|
1537
513
|
function parseBindTextsForElement(bindText) {
|
|
1538
|
-
const [...bindTexts] = bindText
|
|
514
|
+
const [...bindTexts] = splitBindTexts(bindText).map(trimFn).filter(s => s.length > 0);
|
|
1539
515
|
const results = bindTexts.map((bindText) => {
|
|
1540
516
|
const separatorIndex = bindText.indexOf(PROP_VALUE_SEPARATOR);
|
|
1541
517
|
if (separatorIndex === -1) {
|
|
@@ -1543,7 +519,17 @@ function parseBindTextsForElement(bindText) {
|
|
|
1543
519
|
}
|
|
1544
520
|
const propPart = bindText.slice(0, separatorIndex).trim();
|
|
1545
521
|
const statePart = bindText.slice(separatorIndex + 1).trim();
|
|
522
|
+
// 種別は修飾子・入力フィルタより前の名前で決める(要件 B4)。以前は左辺全体との完全一致で
|
|
523
|
+
// 判定していたので、`radio#ro:` が汎用プロパティに落ちていた
|
|
524
|
+
const keyword = propPart.split(MODIFIER_SEPARATOR)[0].split(FILTER_SEPARATOR)[0].trim();
|
|
525
|
+
if (keyword !== propPart && KEYWORDS_WITHOUT_MODIFIERS.has(keyword)) {
|
|
526
|
+
raiseError(`[wcs/binding-syntax] "${bindText}": "${keyword}" takes no modifiers or filters on its left side — write "${keyword}:".${LINT_HINT}`);
|
|
527
|
+
}
|
|
1546
528
|
if (propPart === ELSE_KEYWORD) {
|
|
529
|
+
if (statePart.length > 0) {
|
|
530
|
+
// else は値を取らない(要件 B2)。以前は右辺を黙って捨てていた
|
|
531
|
+
raiseError(`[wcs/binding-syntax] "${bindText}": "else" takes no value — write "else:".${LINT_HINT}`);
|
|
532
|
+
}
|
|
1547
533
|
const pathInfo = getPathInfo('#else');
|
|
1548
534
|
return {
|
|
1549
535
|
propName: ELSE_KEYWORD,
|
|
@@ -1575,9 +561,7 @@ function parseBindTextsForElement(bindText) {
|
|
|
1575
561
|
}
|
|
1576
562
|
else if (propPart === 'if'
|
|
1577
563
|
|| propPart === 'elseif'
|
|
1578
|
-
|| propPart === 'for'
|
|
1579
|
-
|| propPart === 'radio'
|
|
1580
|
-
|| propPart === 'checkbox') {
|
|
564
|
+
|| propPart === 'for') {
|
|
1581
565
|
const stateResult = parseStatePart(statePart);
|
|
1582
566
|
return {
|
|
1583
567
|
propName: propPart,
|
|
@@ -1588,6 +572,16 @@ function parseBindTextsForElement(bindText) {
|
|
|
1588
572
|
bindingType: propPart,
|
|
1589
573
|
};
|
|
1590
574
|
}
|
|
575
|
+
else if (keyword === 'radio' || keyword === 'checkbox') {
|
|
576
|
+
// 修飾子(`#ro`・`#onchange` …)と入力フィルタは radio / checkbox のハンドラが読む(要件 B4)
|
|
577
|
+
const stateResult = parseStatePart(statePart);
|
|
578
|
+
const propResult = parsePropPart(propPart);
|
|
579
|
+
return {
|
|
580
|
+
...propResult,
|
|
581
|
+
...stateResult,
|
|
582
|
+
bindingType: keyword,
|
|
583
|
+
};
|
|
584
|
+
}
|
|
1591
585
|
else {
|
|
1592
586
|
const stateResult = parseStatePart(statePart);
|
|
1593
587
|
const propResult = parsePropPart(propPart);
|
|
@@ -1683,4 +677,4 @@ function clearParserCaches() {
|
|
|
1683
677
|
clearFilterFnCacheForTooling();
|
|
1684
678
|
}
|
|
1685
679
|
|
|
1686
|
-
export { clearParserCaches,
|
|
680
|
+
export { clearParserCaches, getPathInfo, parseBindTextForEmbeddedNode, parseBindTextsForElement, splitBindTexts };
|