From 47b0317e43d38505ccb268b59c65bb40ba81730e Mon Sep 17 00:00:00 2001 From: GODrums Date: Sat, 13 Jun 2026 20:23:37 +0200 Subject: [PATCH 1/8] replace filtrex with custom expression compiler --- package-lock.json | 6 - package.json | 1 - src/lib/components/filter/filter_creator.ts | 2 +- .../components/market/react/filter_panel.ts | 61 ++ src/lib/components/market/react/listing.ts | 27 + src/lib/filter/compile_expression.ts | 564 ++++++++++++++++++ src/lib/filter/filter.ts | 13 +- src/lib/page_scripts/market_listing.ts | 1 + 8 files changed, 659 insertions(+), 16 deletions(-) create mode 100644 src/lib/components/market/react/filter_panel.ts create mode 100644 src/lib/filter/compile_expression.ts diff --git a/package-lock.json b/package-lock.json index a25e3aba..4eba87a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,6 @@ "fast-json-stable-stringify": "^2.1.0", "file-loader": "^6.2.0", "file-replace-loader": "^1.4.2", - "filtrex": "^3.1.0", "glob": "^11.0.1", "globals": "^16.0.0", "html-loader": "^5.1.0", @@ -2048,11 +2047,6 @@ "node": ">=8" } }, - "node_modules/filtrex": { - "version": "3.1.0", - "dev": true, - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "dev": true, diff --git a/package.json b/package.json index b6cfae93..e42c8f9c 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,6 @@ "fast-json-stable-stringify": "^2.1.0", "file-loader": "^6.2.0", "file-replace-loader": "^1.4.2", - "filtrex": "^3.1.0", "glob": "^11.0.1", "globals": "^16.0.0", "html-loader": "^5.1.0", diff --git a/src/lib/components/filter/filter_creator.ts b/src/lib/components/filter/filter_creator.ts index 17abe5ea..f84a2715 100644 --- a/src/lib/components/filter/filter_creator.ts +++ b/src/lib/components/filter/filter_creator.ts @@ -1,4 +1,4 @@ -import {css, html, HTMLTemplateResult, nothing} from 'lit'; +import {css, html, nothing} from 'lit'; import {styleMap} from 'lit-html/directives/style-map.js'; import {state, query} from 'lit/decorators.js'; diff --git a/src/lib/components/market/react/filter_panel.ts b/src/lib/components/market/react/filter_panel.ts new file mode 100644 index 00000000..14cb29c5 --- /dev/null +++ b/src/lib/components/market/react/filter_panel.ts @@ -0,0 +1,61 @@ +import {css, html, nothing, HTMLTemplateResult} from 'lit'; +import {CustomElement, InjectBefore, InjectionMode} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {isReactSteamMarket} from '../mode'; + +import '../../filter/filter_container'; + +/** + * Steam Market Beta equivalent of {@link UtilityBelt}. + */ +@CustomElement() +@InjectBefore( + 'div:has(> [style*="grid-columns:repeat(auto-fill, minmax(260px"])', + InjectionMode.CONTINUOUS, + isReactSteamMarket +) +export class BetaFilterPanel extends FloatElement { + static styles = [ + ...FloatElement.styles, + css` + .panel { + margin-bottom: 16px; + padding: 16px; + background-color: rgba(0, 0, 0, 0.25); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 8px; + color: #c7d5e0; + font-family: 'Motiva Sans', sans-serif; + } + + .panel-title { + font-family: 'Motiva Sans', sans-serif; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #ebebeb; + margin: 0 0 12px; + } + `, + ]; + + private get key(): string { + return marketHashName() ?? ''; + } + + protected render(): HTMLTemplateResult | typeof nothing { + if (!this.key) return nothing; + return html` +
+

CSFloat Filters

+ +
+ `; + } +} + +/** Use the title of the page to get the market hash name */ +function marketHashName(): string | undefined { + return document.title.match(/^(.+?) - Steam Community Market$/)?.[1] ?? undefined; +} diff --git a/src/lib/components/market/react/listing.ts b/src/lib/components/market/react/listing.ts index 3f1825c5..a6b1c76a 100644 --- a/src/lib/components/market/react/listing.ts +++ b/src/lib/components/market/react/listing.ts @@ -1,10 +1,13 @@ import {css, nothing} from 'lit'; +import type {Subscription} from 'rxjs'; import {ItemInfo} from '../../../bridge/handlers/fetch_inspect_info'; import {FloatElement} from '../../custom'; import {CustomElement, InjectAppend, InjectionMode} from '../../injectors'; import {isReactSteamMarket} from '../mode'; import {gFloatFetcher} from '../../../services/float_fetcher'; +import {gFilterService} from '../../../services/filter'; +import {pickTextColour} from '../../../utils/colours'; import {BetaListingRank} from './rank'; import {BetaListingSeedInfo} from './seed_info'; @@ -23,6 +26,7 @@ import type {MarketListing, MarketListingProps} from './types'; export class BetaListingEnhancer extends FloatElement { private rankInjected = false; private seedInfoInjected = false; + private filterSubscription?: Subscription; static styles = [ css` @@ -84,6 +88,15 @@ export class BetaListingEnhancer extends FloatElement { return Number(rawFloat); } + get convertedPrice(): number | undefined { + const listing = this.listing; + if (!listing || !listing.unPrice) { + return; + } + + return (listing.unPrice + listing.unFee) / 100; + } + connectedCallback(): void { super.connectedCallback(); @@ -94,6 +107,8 @@ export class BetaListingEnhancer extends FloatElement { disconnectedCallback(): void { super.disconnectedCallback(); + this.filterSubscription?.unsubscribe(); + this.filterSubscription = undefined; } protected render(): typeof nothing { @@ -112,6 +127,18 @@ export class BetaListingEnhancer extends FloatElement { this.injectRank(info); this.injectSeedInfo(info); + this.applyFilterColour(info); + } + + private applyFilterColour(info: ItemInfo): void { + this.filterSubscription?.unsubscribe(); + this.filterSubscription = gFilterService.onUpdate$.subscribe(() => { + if (!this.isConnected) return; + + const colour = gFilterService.matchColour(info, this.convertedPrice) || ''; + this.card.style.backgroundColor = colour; + this.card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : ''; + }); } private injectRank(info: ItemInfo): void { diff --git a/src/lib/filter/compile_expression.ts b/src/lib/filter/compile_expression.ts new file mode 100644 index 00000000..6611f179 --- /dev/null +++ b/src/lib/filter/compile_expression.ts @@ -0,0 +1,564 @@ +import type {InternalInputVars} from './types'; + +export type FilterValue = number | string | boolean | Error; +type Callable = (...args: any[]) => FilterValue; +type Runner = (vars: InternalInputVars) => FilterValue; + +interface CompileOptions { + extraFunctions?: Record; +} + +type TokenType = 'number' | 'string' | 'identifier' | 'operator' | 'paren' | 'comma' | 'eof'; + +interface Token { + type: TokenType; + value: string; + position: number; +} + +type AstNode = + | {type: 'literal'; value: number | string | boolean} + | {type: 'identifier'; name: string} + | {type: 'unary'; operator: string; argument: AstNode} + | {type: 'binary'; operator: string; left: AstNode; right: AstNode} + | {type: 'call'; name: string; args: AstNode[]} + | {type: 'list'; values: AstNode[]}; + +const DEFAULT_FUNCTIONS: Record = { + abs: Math.abs, + ceil: Math.ceil, + floor: Math.floor, + log: Math.log, + max: Math.max, + min: Math.min, + random: Math.random, + round: Math.round, + sqrt: Math.sqrt, +}; + +export function compileExpression(expression: string, options: CompileOptions = {}): Runner { + const tokens = tokenize(expression); + const parser = new Parser(tokens); + const ast = parser.parse(); + const functions = { + ...DEFAULT_FUNCTIONS, + ...(options.extraFunctions || {}), + }; + + return (vars: InternalInputVars) => { + try { + return evaluate(ast, vars, functions); + } catch (e: any) { + return e instanceof Error ? e : new Error(String(e)); + } + }; +} + +function tokenize(expression: string): Token[] { + const tokens: Token[] = []; + let pos = 0; + + while (pos < expression.length) { + const char = expression[pos]; + + if (/\s/.test(char)) { + pos++; + continue; + } + + if (char === '"' || char === "'") { + const start = pos; + const quote = char; + let value = ''; + pos++; + + while (pos < expression.length) { + const current = expression[pos++]; + + if (current === quote) { + tokens.push({type: 'string', value, position: start}); + break; + } + + if (current === '\\') { + if (pos >= expression.length) { + throw new Error(`unterminated escape sequence at ${start}`); + } + + value += readEscapedCharacter(expression[pos++]); + } else { + value += current; + } + } + + if (tokens[tokens.length - 1]?.position !== start) { + throw new Error(`unterminated string at ${start}`); + } + + continue; + } + + if (isNumberStart(expression, pos)) { + const start = pos; + pos = readNumber(expression, pos); + tokens.push({type: 'number', value: expression.slice(start, pos), position: start}); + continue; + } + + if (/[A-Za-z_]/.test(char)) { + const start = pos; + pos++; + + while (pos < expression.length && /[A-Za-z0-9_]/.test(expression[pos])) { + pos++; + } + + const value = expression.slice(start, pos); + const normalized = value.toLowerCase(); + if (['and', 'or', 'not', 'in'].includes(normalized)) { + tokens.push({type: 'operator', value: normalized, position: start}); + } else { + tokens.push({type: 'identifier', value, position: start}); + } + + continue; + } + + const twoCharOperator = expression.slice(pos, pos + 2); + if (['>=', '<=', '==', '!='].includes(twoCharOperator)) { + tokens.push({type: 'operator', value: twoCharOperator, position: pos}); + pos += 2; + continue; + } + + if (['>', '<', '+', '-', '*', '/', '%', '^', '='].includes(char)) { + tokens.push({type: 'operator', value: char, position: pos}); + pos++; + continue; + } + + if (char === '(' || char === ')') { + tokens.push({type: 'paren', value: char, position: pos}); + pos++; + continue; + } + + if (char === ',') { + tokens.push({type: 'comma', value: char, position: pos}); + pos++; + continue; + } + + throw new Error(`unexpected character "${char}" at ${pos}`); + } + + tokens.push({type: 'eof', value: '', position: expression.length}); + return tokens; +} + +function readEscapedCharacter(char: string): string { + switch (char) { + case 'n': + return '\n'; + case 'r': + return '\r'; + case 't': + return '\t'; + default: + return char; + } +} + +function isNumberStart(expression: string, pos: number): boolean { + const char = expression[pos]; + const next = expression[pos + 1]; + return /[0-9]/.test(char) || (char === '.' && /[0-9]/.test(next)); +} + +function readNumber(expression: string, pos: number): number { + const start = pos; + + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + + if (expression[pos] === '.') { + pos++; + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + } + + if (expression[pos]?.toLowerCase() === 'e') { + const exponentStart = pos; + pos++; + if (expression[pos] === '+' || expression[pos] === '-') pos++; + + const digitsStart = pos; + while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + + if (digitsStart === pos) { + throw new Error(`invalid number at ${start}: ${expression.slice(start, exponentStart + 1)}`); + } + } + + return pos; +} + +class Parser { + private position = 0; + + constructor(private tokens: Token[]) {} + + parse(): AstNode { + const expression = this.parseOr(); + this.expect('eof'); + return expression; + } + + private parseOr(): AstNode { + let node = this.parseAnd(); + + while (this.matchOperator('or')) { + node = {type: 'binary', operator: 'or', left: node, right: this.parseAnd()}; + } + + return node; + } + + private parseAnd(): AstNode { + let node = this.parseNot(); + + while (this.matchOperator('and')) { + node = {type: 'binary', operator: 'and', left: node, right: this.parseNot()}; + } + + return node; + } + + private parseNot(): AstNode { + if (this.matchOperator('not')) { + return {type: 'unary', operator: 'not', argument: this.parseNot()}; + } + + return this.parseComparison(); + } + + private parseComparison(): AstNode { + let node = this.parseAdditive(); + + while (true) { + const operator = this.peek(); + + if ( + operator.type === 'operator' && + ['==', '=', '!=', '<', '<=', '>', '>=', 'in'].includes(operator.value) + ) { + this.advance(); + node = { + type: 'binary', + operator: operator.value, + left: node, + right: operator.value === 'in' ? this.parseList() : this.parseAdditive(), + }; + continue; + } + + if (this.matchOperator('not')) { + this.expect('operator', 'in'); + node = {type: 'binary', operator: 'not in', left: node, right: this.parseList()}; + continue; + } + + return node; + } + } + + private parseAdditive(): AstNode { + let node = this.parseMultiplicative(); + + while (this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) { + const operator = this.advance().value; + node = {type: 'binary', operator, left: node, right: this.parseMultiplicative()}; + } + + return node; + } + + private parseMultiplicative(): AstNode { + let node = this.parsePower(); + + while (this.peek().type === 'operator' && ['*', '/', '%'].includes(this.peek().value)) { + const operator = this.advance().value; + node = {type: 'binary', operator, left: node, right: this.parsePower()}; + } + + return node; + } + + private parsePower(): AstNode { + const node = this.parseUnary(); + + if (this.matchOperator('^')) { + return {type: 'binary', operator: '^', left: node, right: this.parsePower()}; + } + + return node; + } + + private parseUnary(): AstNode { + if (this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) { + const operator = this.advance().value; + return {type: 'unary', operator, argument: this.parseUnary()}; + } + + return this.parsePrimary(); + } + + private parsePrimary(): AstNode { + const token = this.peek(); + + if (token.type === 'number') { + this.advance(); + return {type: 'literal', value: Number(token.value)}; + } + + if (token.type === 'string') { + this.advance(); + return {type: 'literal', value: token.value}; + } + + if (token.type === 'identifier') { + this.advance(); + + if (this.match('paren', '(')) { + const args: AstNode[] = []; + + if (!this.match('paren', ')')) { + do { + args.push(this.parseOr()); + } while (this.match('comma')); + + this.expect('paren', ')'); + } + + return {type: 'call', name: token.value, args}; + } + + const lowerName = token.value.toLowerCase(); + if (lowerName === 'true' || lowerName === 'false') { + return {type: 'literal', value: lowerName === 'true'}; + } + + return {type: 'identifier', name: token.value}; + } + + if (this.match('paren', '(')) { + const node = this.parseOr(); + this.expect('paren', ')'); + return node; + } + + throw new Error(`unexpected token "${token.value}" at ${token.position}`); + } + + private parseList(): AstNode { + this.expect('paren', '('); + const values: AstNode[] = []; + + if (!this.match('paren', ')')) { + do { + values.push(this.parseOr()); + } while (this.match('comma')); + + this.expect('paren', ')'); + } + + return {type: 'list', values}; + } + + private match(type: TokenType, value?: string): boolean { + const token = this.peek(); + if (token.type !== type || (value !== undefined && token.value !== value)) return false; + this.advance(); + return true; + } + + private matchOperator(value: string): boolean { + return this.match('operator', value); + } + + private expect(type: TokenType, value?: string): Token { + const token = this.peek(); + if (token.type !== type || (value !== undefined && token.value !== value)) { + const expected = value === undefined ? type : `${type} "${value}"`; + throw new Error(`expected ${expected} at ${token.position}`); + } + + return this.advance(); + } + + private peek(): Token { + return this.tokens[this.position]; + } + + private advance(): Token { + return this.tokens[this.position++]; + } +} + +function evaluate(node: AstNode, vars: InternalInputVars, functions: Record): FilterValue { + switch (node.type) { + case 'literal': + return node.value; + case 'identifier': + return readVariable(vars, node.name); + case 'unary': + return evaluateUnary(node.operator, evaluate(node.argument, vars, functions)); + case 'binary': + return evaluateBinary(node, vars, functions); + case 'call': + return evaluateCall(node, vars, functions); + case 'list': + throw new Error('list is only valid with the in operator'); + } +} + +function readVariable(vars: InternalInputVars, name: string): FilterValue { + if (!(name in vars) || (vars as any)[name] === undefined) { + throw new Error(`unknown variable "${name}"`); + } + + const value = (vars as any)[name]; + if (typeof value === 'number' || typeof value === 'string' || typeof value === 'boolean') return value; + + throw new Error(`unsupported variable "${name}"`); +} + +function evaluateUnary(operator: string, value: FilterValue): FilterValue { + assertValue(value); + + switch (operator) { + case '+': + return toNumber(value); + case '-': + return -toNumber(value); + case 'not': + return !isTruthy(value); + default: + throw new Error(`unknown unary operator "${operator}"`); + } +} + +function evaluateBinary( + node: Extract, + vars: InternalInputVars, + functions: Record +): FilterValue { + if (node.operator === 'and') { + const left = evaluate(node.left, vars, functions); + assertValue(left); + return isTruthy(left) && isTruthy(evaluate(node.right, vars, functions)); + } + + if (node.operator === 'or') { + const left = evaluate(node.left, vars, functions); + assertValue(left); + return isTruthy(left) || isTruthy(evaluate(node.right, vars, functions)); + } + + const left = evaluate(node.left, vars, functions); + assertValue(left); + + if (node.operator === 'in' || node.operator === 'not in') { + if (node.right.type !== 'list') throw new Error(`${node.operator} requires a list`); + const found = node.right.values.some((item) => valuesEqual(left, evaluate(item, vars, functions))); + return node.operator === 'in' ? found : !found; + } + + const right = evaluate(node.right, vars, functions); + assertValue(right); + + switch (node.operator) { + case '==': + case '=': + return valuesEqual(left, right); + case '!=': + return !valuesEqual(left, right); + case '<': + return compare(left, right) < 0; + case '<=': + return compare(left, right) <= 0; + case '>': + return compare(left, right) > 0; + case '>=': + return compare(left, right) >= 0; + case '+': + if (typeof left === 'string' || typeof right === 'string') return String(left) + String(right); + return toNumber(left) + toNumber(right); + case '-': + return toNumber(left) - toNumber(right); + case '*': + return toNumber(left) * toNumber(right); + case '/': + return toNumber(left) / toNumber(right); + case '%': + return toNumber(left) % toNumber(right); + case '^': + return Math.pow(toNumber(left), toNumber(right)); + default: + throw new Error(`unknown operator "${node.operator}"`); + } +} + +function evaluateCall( + node: Extract, + vars: InternalInputVars, + functions: Record +): FilterValue { + const func = functions[node.name]; + if (!func) throw new Error(`unknown function "${node.name}"`); + + const args = node.args.map((arg) => { + const value = evaluate(arg, vars, functions); + assertValue(value); + return value; + }); + + return func(...args); +} + +function valuesEqual(left: FilterValue, right: FilterValue): boolean { + assertValue(left); + assertValue(right); + + return left === right; +} + +function compare(left: FilterValue, right: FilterValue): number { + assertValue(left); + assertValue(right); + + if (typeof left === 'number' && typeof right === 'number') return left - right; + if (typeof left === 'string' && typeof right === 'string') return left.localeCompare(right); + + throw new Error(`cannot compare ${typeof left} with ${typeof right}`); +} + +function toNumber(value: FilterValue): number { + assertValue(value); + + if (typeof value !== 'number') { + throw new Error(`expected number, got ${typeof value}`); + } + + return value; +} + +function isTruthy(value: FilterValue): boolean { + assertValue(value); + + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0 && !Number.isNaN(value); + return value.length > 0; +} + +function assertValue(value: FilterValue): asserts value is number | string | boolean { + if (value instanceof Error) throw value; +} diff --git a/src/lib/filter/filter.ts b/src/lib/filter/filter.ts index 5b856f9f..6f41c582 100644 --- a/src/lib/filter/filter.ts +++ b/src/lib/filter/filter.ts @@ -1,8 +1,8 @@ import {InternalInputVars, SerializedFilter} from './types'; import {match, percentile, percentileRange} from './custom_functions'; -import {compileExpression} from 'filtrex'; +import {compileExpression, type FilterValue} from './compile_expression'; -type ExpressionRunner = (data: InternalInputVars) => boolean; +type ExpressionRunner = (data: InternalInputVars) => FilterValue; /** * Encapsulates a filter, with mechanisms for running expressions @@ -69,7 +69,7 @@ export class Filter { }; } - run(vars: InternalInputVars): any { + run(vars: InternalInputVars): FilterValue { // Update vars in use for the functions this.currentVars = vars; @@ -86,12 +86,9 @@ export class Filter { /** * Whether the return value from {@link run} is "valid" or usable * for comparison purposes. - * - * For instance, will return false if `result` is an error indicating - * a property is undefined. */ - static isValidReturnValue(result: any): boolean { - return typeof result === 'boolean' || result === 0 || result === 1; + static isValidReturnValue(result: FilterValue): boolean { + return result !== undefined && result !== null && !(result instanceof Error); } /** diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index 55b425b8..aba68349 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -2,6 +2,7 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; import '../components/market/react/listing'; +import '../components/market/react/filter_panel'; init('src/lib/page_scripts/market_listing.js', main); From c62d8248482223115d75ff57e60132a649d97d09 Mon Sep 17 00:00:00 2001 From: GODrums Date: Mon, 15 Jun 2026 02:29:35 +0200 Subject: [PATCH 2/8] add comments to parser --- src/lib/filter/compile_expression.ts | 117 +++++++++++++++++++++++++-- 1 file changed, 109 insertions(+), 8 deletions(-) diff --git a/src/lib/filter/compile_expression.ts b/src/lib/filter/compile_expression.ts index 6611f179..1cfabb80 100644 --- a/src/lib/filter/compile_expression.ts +++ b/src/lib/filter/compile_expression.ts @@ -1,5 +1,12 @@ import type {InternalInputVars} from './types'; +/* + * A tiny AST-based expression engine for user-authored filters. + * + * Produces either a number, string, boolean, or an error, but never throws. + * The result is a Runner function that can be called with a set of variables to produce a single result. + */ + export type FilterValue = number | string | boolean | Error; type Callable = (...args: any[]) => FilterValue; type Runner = (vars: InternalInputVars) => FilterValue; @@ -13,6 +20,7 @@ type TokenType = 'number' | 'string' | 'identifier' | 'operator' | 'paren' | 'co interface Token { type: TokenType; value: string; + /** Index of the token's first character in the source string, used for error messages. */ position: number; } @@ -36,14 +44,25 @@ const DEFAULT_FUNCTIONS: Record = { sqrt: Math.sqrt, }; +/** + * Compiles an expression once into a reusable {@link Runner}. + * This is the expensive part, so it can be cached and reused. + */ export function compileExpression(expression: string, options: CompileOptions = {}): Runner { - const tokens = tokenize(expression); - const parser = new Parser(tokens); - const ast = parser.parse(); - const functions = { - ...DEFAULT_FUNCTIONS, - ...(options.extraFunctions || {}), - }; + // Null prototype prevents user identifiers from being callable. + const functions: Record = Object.assign( + Object.create(null), + DEFAULT_FUNCTIONS, + options.extraFunctions + ); + + let ast: AstNode; + try { + const tokens = tokenize(expression); + ast = new Parser(tokens).parse(); + } catch (e) { + return errorRunner(e); + } return (vars: InternalInputVars) => { try { @@ -54,6 +73,17 @@ export function compileExpression(expression: string, options: CompileOptions = }; } +/** Builds a runner that ignores its input and always returns the given error. */ +function errorRunner(e: unknown): Runner { + const error = e instanceof Error ? e : new Error(String(e)); + return () => error; +} + +/** + * Stage 1: turns the raw source into a flat token list. A single forward scan with no backtracking; + * each branch consumes one token's worth of characters and advances `pos`. Throws on malformed input + * (unterminated string, stray character, ...). A trailing `eof` token lets the parser peek safely. + */ function tokenize(expression: string): Token[] { const tokens: Token[] = []; let pos = 0; @@ -66,6 +96,7 @@ function tokenize(expression: string): Token[] { continue; } + // String literal: supports both quote styles and backslash escapes (see readEscapedCharacter). if (char === '"' || char === "'") { const start = pos; const quote = char; @@ -91,6 +122,8 @@ function tokenize(expression: string): Token[] { } } + // We only pushed a token above if the closing quote was found. Since each token's position + // is unique, a missing token at `start` means we ran off the end without closing the string. if (tokens[tokens.length - 1]?.position !== start) { throw new Error(`unterminated string at ${start}`); } @@ -105,6 +138,7 @@ function tokenize(expression: string): Token[] { continue; } + // A word: either a reserved word operator or an identifier. `true`/`false` stay identifiers here and become literals later. if (/[A-Za-z_]/.test(char)) { const start = pos; pos++; @@ -156,6 +190,7 @@ function tokenize(expression: string): Token[] { return tokens; } +/** Maps the character after a backslash to its value. Unknown escapes are kept literal (e.g. `\"` -> `"`). */ function readEscapedCharacter(char: string): string { switch (char) { case 'n': @@ -169,22 +204,31 @@ function readEscapedCharacter(char: string): string { } } +/** True if a number token starts here: a digit, or a leading dot immediately followed by a digit (`.5`). */ function isNumberStart(expression: string, pos: number): boolean { const char = expression[pos]; const next = expression[pos + 1]; return /[0-9]/.test(char) || (char === '.' && /[0-9]/.test(next)); } +/** + * Scans a numeric literal (integer, decimal, or scientific notation) and returns the index just past it. + * The actual numeric conversion is deferred to the parser; here we only delimit the token. Throws if an + * `e` exponent has no digits (e.g. `1e`). + */ function readNumber(expression: string, pos: number): number { const start = pos; + // Integer part. while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; + // Optional fractional part. if (expression[pos] === '.') { pos++; while (pos < expression.length && /[0-9]/.test(expression[pos])) pos++; } + // Optional exponent, e.g. `e-10`. if (expression[pos]?.toLowerCase() === 'e') { const exponentStart = pos; pos++; @@ -201,17 +245,29 @@ function readNumber(expression: string, pos: number): number { return pos; } +/** + * Stage 2: recursive-descent parser. Each `parseX` method handles one precedence level and delegates + * to the next-tighter-binding level, so the call chain itself encodes precedence from loosest to + * tightest: + * + * or -> and -> not -> comparison/in -> additive -> multiplicative -> power -> unary -> primary + * + * Most binary operators are left-associative (loop and fold left). `^` is right-associative and `not` + * is a prefix unary, so those recurse into themselves instead of looping. + */ class Parser { private position = 0; constructor(private tokens: Token[]) {} + /** Parses the whole token stream and asserts nothing is left over after the expression. */ parse(): AstNode { const expression = this.parseOr(); this.expect('eof'); return expression; } + /** Lowest precedence: `a or b or c`, left-associative. */ private parseOr(): AstNode { let node = this.parseAnd(); @@ -222,6 +278,7 @@ class Parser { return node; } + /** `a and b`, binds tighter than `or`, left-associative. */ private parseAnd(): AstNode { let node = this.parseNot(); @@ -232,6 +289,7 @@ class Parser { return node; } + /** Prefix `not`. Recurses so `not not x` chains; binds looser than comparisons (`not a == b`). */ private parseNot(): AstNode { if (this.matchOperator('not')) { return {type: 'unary', operator: 'not', argument: this.parseNot()}; @@ -240,6 +298,11 @@ class Parser { return this.parseComparison(); } + /** + * Comparisons and membership tests. The loop allows folding (`a < b < c` parses as `(a < b) < c`) + * rather than rejecting it. `in` / `not in` take a parenthesized list on the right; everything else + * takes an arithmetic operand. + */ private parseComparison(): AstNode { let node = this.parseAdditive(); @@ -260,6 +323,7 @@ class Parser { continue; } + // `not` appearing here can only be the start of the two-word `not in` operator. if (this.matchOperator('not')) { this.expect('operator', 'in'); node = {type: 'binary', operator: 'not in', left: node, right: this.parseList()}; @@ -270,6 +334,7 @@ class Parser { } } + /** `+` and `-`, left-associative. */ private parseAdditive(): AstNode { let node = this.parseMultiplicative(); @@ -281,6 +346,7 @@ class Parser { return node; } + /** `*`, `/`, `%`, left-associative; binds tighter than `+`/`-`. */ private parseMultiplicative(): AstNode { let node = this.parsePower(); @@ -292,6 +358,7 @@ class Parser { return node; } + /** Exponentiation `^`, right-associative: `2 ^ 3 ^ 2` parses as `2 ^ (3 ^ 2)`. */ private parsePower(): AstNode { const node = this.parseUnary(); @@ -302,6 +369,7 @@ class Parser { return node; } + /** Prefix sign `+x` / `-x`. Binds tighter than `^`, so `-2 ^ 2` is `(-2) ^ 2`. */ private parseUnary(): AstNode { if (this.peek().type === 'operator' && ['+', '-'].includes(this.peek().value)) { const operator = this.advance().value; @@ -311,6 +379,10 @@ class Parser { return this.parsePrimary(); } + /** + * Tightest level: literals, `true`/`false`, parenthesized sub-expressions, variable references, and + * function calls (an identifier immediately followed by `(`). + */ private parsePrimary(): AstNode { const token = this.peek(); @@ -327,6 +399,7 @@ class Parser { if (token.type === 'identifier') { this.advance(); + // `name(...)` -> function call. Arguments are full expressions, comma-separated. if (this.match('paren', '(')) { const args: AstNode[] = []; @@ -341,6 +414,7 @@ class Parser { return {type: 'call', name: token.value, args}; } + // Bare `true`/`false` are boolean literals; anything else is a variable reference. const lowerName = token.value.toLowerCase(); if (lowerName === 'true' || lowerName === 'false') { return {type: 'literal', value: lowerName === 'true'}; @@ -349,6 +423,7 @@ class Parser { return {type: 'identifier', name: token.value}; } + // Parenthesized grouping, e.g. `(a + b) * c`. if (this.match('paren', '(')) { const node = this.parseOr(); this.expect('paren', ')'); @@ -358,6 +433,7 @@ class Parser { throw new Error(`unexpected token "${token.value}" at ${token.position}`); } + /** Parses the `(...)` operand of `in` / `not in` into a list node. Allows an empty list `()`. */ private parseList(): AstNode { this.expect('paren', '('); const values: AstNode[] = []; @@ -373,6 +449,7 @@ class Parser { return {type: 'list', values}; } + /** Consumes the next token and returns true if it matches the given type (and value, if provided). */ private match(type: TokenType, value?: string): boolean { const token = this.peek(); if (token.type !== type || (value !== undefined && token.value !== value)) return false; @@ -384,6 +461,7 @@ class Parser { return this.match('operator', value); } + /** Similar to {@link match} but throws a positioned error when the expected token isn't present. */ private expect(type: TokenType, value?: string): Token { const token = this.peek(); if (token.type !== type || (value !== undefined && token.value !== value)) { @@ -394,15 +472,21 @@ class Parser { return this.advance(); } + /** Get the current token without consuming it */ private peek(): Token { return this.tokens[this.position]; } + /** Like {@link peek} but consumes the token. */ private advance(): Token { return this.tokens[this.position++]; } } +/** + * Stage 3: recursively evaluates an AST node to a value. Mirrors the node shapes produced by the parser. + * Errors thrown here (and by the helpers below) are caught by the runner in {@link compileExpression}. + */ function evaluate(node: AstNode, vars: InternalInputVars, functions: Record): FilterValue { switch (node.type) { case 'literal': @@ -420,8 +504,11 @@ function evaluate(node: AstNode, vars: InternalInputVars, functions: Record ): FilterValue { + // `and` / `or` short-circuit: the right side is only evaluated when the left doesn't already decide the result. if (node.operator === 'and') { const left = evaluate(node.left, vars, functions); assertValue(left); @@ -466,6 +554,7 @@ function evaluateBinary( const left = evaluate(node.left, vars, functions); assertValue(left); + // Membership: the right side is a list node, so check the left against each element by equality. if (node.operator === 'in' || node.operator === 'not in') { if (node.right.type !== 'list') throw new Error(`${node.operator} requires a list`); const found = node.right.values.some((item) => valuesEqual(left, evaluate(item, vars, functions))); @@ -490,6 +579,7 @@ function evaluateBinary( case '>=': return compare(left, right) >= 0; case '+': + // `+` is overloaded: string concatenation if either side is a string, numeric addition otherwise. if (typeof left === 'string' || typeof right === 'string') return String(left) + String(right); return toNumber(left) + toNumber(right); case '-': @@ -515,6 +605,7 @@ function evaluateCall( const func = functions[node.name]; if (!func) throw new Error(`unknown function "${node.name}"`); + // Evaluate every argument eagerly (no per-function short-circuiting) before invoking. const args = node.args.map((arg) => { const value = evaluate(arg, vars, functions); assertValue(value); @@ -524,6 +615,7 @@ function evaluateCall( return func(...args); } +/** Equality for `==` / `!=`: strict, so `5 == "5"` is false. No cross-type coercion. */ function valuesEqual(left: FilterValue, right: FilterValue): boolean { assertValue(left); assertValue(right); @@ -531,6 +623,10 @@ function valuesEqual(left: FilterValue, right: FilterValue): boolean { return left === right; } +/** + * Three-way comparison for `<`, `<=`, `>`, `>=`. Numbers compare numerically and strings + * lexicographically; cross-type comparison throws an error. + */ function compare(left: FilterValue, right: FilterValue): number { assertValue(left); assertValue(right); @@ -541,6 +637,7 @@ function compare(left: FilterValue, right: FilterValue): number { throw new Error(`cannot compare ${typeof left} with ${typeof right}`); } +/** Coerces to a number for arithmetic, or throws. Deliberately strict: strings are not auto-parsed. */ function toNumber(value: FilterValue): number { assertValue(value); @@ -551,6 +648,7 @@ function toNumber(value: FilterValue): number { return value; } +/** Truthiness for boolean contexts: false-y are `false`, `0`, `NaN`, and `""`. */ function isTruthy(value: FilterValue): boolean { assertValue(value); @@ -559,6 +657,9 @@ function isTruthy(value: FilterValue): boolean { return value.length > 0; } +/** + * Narrows away the `Error` arm of {@link FilterValue}: if an `Error` reached here it means a sub-result failed, so re-throw it to abort evaluation. + */ function assertValue(value: FilterValue): asserts value is number | string | boolean { if (value instanceof Error) throw value; } From 6736e91709ea8d17dc9d31b47bfedf110d7ad734 Mon Sep 17 00:00:00 2001 From: GODrums Date: Sun, 5 Jul 2026 17:54:30 +0200 Subject: [PATCH 3/8] reimplement card highlighting subscriber --- .../components/market/react/filter_panel.ts | 2 +- src/lib/components/market/react/highlight.ts | 49 +++++++++++++++++++ src/lib/page_scripts/market_listing.ts | 2 +- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/market/react/highlight.ts diff --git a/src/lib/components/market/react/filter_panel.ts b/src/lib/components/market/react/filter_panel.ts index 14cb29c5..a6dd9fcb 100644 --- a/src/lib/components/market/react/filter_panel.ts +++ b/src/lib/components/market/react/filter_panel.ts @@ -14,7 +14,7 @@ import '../../filter/filter_container'; InjectionMode.CONTINUOUS, isReactSteamMarket ) -export class BetaFilterPanel extends FloatElement { +export class ReactFilterPanel extends FloatElement { static styles = [ ...FloatElement.styles, css` diff --git a/src/lib/components/market/react/highlight.ts b/src/lib/components/market/react/highlight.ts new file mode 100644 index 00000000..7fba5bc2 --- /dev/null +++ b/src/lib/components/market/react/highlight.ts @@ -0,0 +1,49 @@ +import {nothing} from 'lit'; +import {property} from 'lit/decorators.js'; +import type {Subscription} from 'rxjs'; +import {CustomElement, InjectIntoScope} from '../../injectors'; +import {FloatElement} from '../../custom'; +import {gFilterService} from '../../../services/filter'; +import {pickTextColour} from '../../../utils/colours'; +import {ReactMarketListingScope, type ReactListingContext} from './listing'; + +@CustomElement() +@InjectIntoScope(ReactMarketListingScope, { + anchor: ({scope}) => scope, +}) +export class ReactListingHighlight extends FloatElement { + @property({attribute: false}) injectionContext?: ReactListingContext; + + private filterSubscription?: Subscription; + + connectedCallback(): void { + super.connectedCallback(); + this.filterSubscription = gFilterService.onUpdate$.subscribe(() => this.applyColour()); + } + + disconnectedCallback(): void { + super.disconnectedCallback(); + this.filterSubscription?.unsubscribe(); + this.filterSubscription = undefined; + } + + private get convertedPrice(): number | undefined { + const listing = this.injectionContext?.listing; + if (!listing?.unPrice) return undefined; + return (listing.unPrice + listing.unFee) / 100; + } + + private applyColour(): void { + const card = this.parentElement; + const context = this.injectionContext; + if (!card || !context) return; + + const colour = gFilterService.matchColour(context.itemInfo, this.convertedPrice) || ''; + card.style.backgroundColor = colour; + card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : ''; + } + + protected render() { + return nothing; + } +} diff --git a/src/lib/page_scripts/market_listing.ts b/src/lib/page_scripts/market_listing.ts index 6b72bfba..19010b53 100644 --- a/src/lib/page_scripts/market_listing.ts +++ b/src/lib/page_scripts/market_listing.ts @@ -1,10 +1,10 @@ import {init} from './utils'; import '../components/market/item_row_wrapper'; import '../components/market/utility_belt'; -import '../components/market/react/listing'; import '../components/market/react/filter_panel'; import '../components/market/react/rank'; import '../components/market/react/seed_info'; +import '../components/market/react/highlight'; init('src/lib/page_scripts/market_listing.js', main); From 86a7466716f087601a2c50e7bce8c45b351d3f8e Mon Sep 17 00:00:00 2001 From: GODrums Date: Sun, 5 Jul 2026 19:10:39 +0200 Subject: [PATCH 4/8] fix: replace MHN with market ID --- src/lib/components/market/react/filter_panel.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/lib/components/market/react/filter_panel.ts b/src/lib/components/market/react/filter_panel.ts index a6dd9fcb..6968a3bc 100644 --- a/src/lib/components/market/react/filter_panel.ts +++ b/src/lib/components/market/react/filter_panel.ts @@ -41,7 +41,7 @@ export class ReactFilterPanel extends FloatElement { ]; private get key(): string { - return marketHashName() ?? ''; + return getSteamMarketID() || ''; } protected render(): HTMLTemplateResult | typeof nothing { @@ -55,7 +55,7 @@ export class ReactFilterPanel extends FloatElement { } } -/** Use the title of the page to get the market hash name */ -function marketHashName(): string | undefined { - return document.title.match(/^(.+?) - Steam Community Market$/)?.[1] ?? undefined; +/** Example: G18FD03209F033003 */ +function getSteamMarketID(): string | undefined { + return location.pathname.split('/').pop(); } From d05d7bf4f0f216d3c330d135988a30ac34c456d2 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 7 Jul 2026 17:36:13 +0200 Subject: [PATCH 5/8] add minimal Vitest setup --- package-lock.json | 1276 +++++++++++++++++++++++++++++++++++++++++++-- package.json | 3 + tsconfig.json | 2 +- vitest.config.ts | 7 + 4 files changed, 1248 insertions(+), 40 deletions(-) create mode 100644 vitest.config.ts diff --git a/package-lock.json b/package-lock.json index 4eba87a2..f785a643 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,6 +51,7 @@ "sass-loader": "^16.0.5", "ts-loader": "^9.5.2", "typescript": "^5.8.3", + "vitest": "^4.1.10", "webpack": "^5.98.0", "webpack-cli": "^6.0.1" } @@ -76,6 +77,40 @@ "node": ">=14.17.0" } }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.5.1", "dev": true, @@ -397,7 +432,9 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true, "license": "MIT" }, @@ -423,6 +460,25 @@ "@lit-labs/ssr-dom-shim": "^1.2.0" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "dev": true, @@ -455,10 +511,331 @@ "node": ">= 8" } }, + "node_modules/@oxc-project/types": { + "version": "0.138.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.138.0.tgz", + "integrity": "sha512-1a7ZKmrRTCoN1XMZ4L0PyyqrMnrNlLyPuOkdSX2MZg7IiIGRUyurNhAm73ptDOraoBcIordsIGKNPKUzy3ZmfA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, "node_modules/@protobuf-ts/runtime": { "version": "2.11.1", "license": "(Apache-2.0 AND BSD-3-Clause)" }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz", + "integrity": "sha512-EZLpf/8y7GXkkra90ML47kzik/GMP3EMcE9bPyHmRfxLC6z9+aW5A8poCsoxjrT5GfEcNAAvWwUHjvP1pUQkfw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.4.tgz", + "integrity": "sha512-aUi+HBvmYb7j8krl1+qJgkG8C17fO79gk3c+jPw4S8glRFc1DTija9S3EyaTSQUm5GJXYKDAsugBEhFHH2vYiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.4.tgz", + "integrity": "sha512-F7hHC3gwY11+vByKPRWqwGbeXWVgKmL+pTGCinaEhdihzBV2aQ0fvZOch9cXYUOKuKKq429HeYXOqQLc7wFCEg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.4.tgz", + "integrity": "sha512-sI5yw+7s92SK6odiEhD5lKCBlWcpjHS5qyqpVQbZAJ0fIzEUXrmbl3DH2ybR3PZogulNJF+COLtmA8hUfvkCCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.4.tgz", + "integrity": "sha512-mCi0OKgEieFircrtVYmQAFGszRtMnZ6fpZAXrxanXAu7lqZcsK1E1RAaZNG0uKAnxox3B1f4EyQNnoyMfN1vAA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.4.tgz", + "integrity": "sha512-B9Ial3Kv5sh0SHnB1g/QWcUQCEvCF6QKGAl4zXypYj65mVI+B4AhFBwPtSN7pDrJeIx8Z7zdy4ntx+wQABom7w==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.4.tgz", + "integrity": "sha512-lZVym0PuHE1KZ22gmFTC15lAkrg9iTszR617oYRB/iPY1A56ywoJzVKOJBKaot5RiikCObmur6pogpse3gRcng==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.4.tgz", + "integrity": "sha512-t2DNiLJWNTbnEHyUzTumldML6ET4/g16467LZoDDJ3tSxGvguL5/NyC2lCsNKuyRycg9XeDQF5SSv+TNOhQEXg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.4.tgz", + "integrity": "sha512-0WIRnL1Uw4BvTZRLQt+PVgo6ZKTJadlC2btP+/EOXv2f/DWbY0rEgl+y834mIVwP1FkTlWVTrGGJXf12lru7EQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.4.tgz", + "integrity": "sha512-JWtGshGfX+oENAKonoNkqEJX+7hC8yfhi9GUyPX1VX4mdh1y5r+ZiJLR5XzAB0aoP6s/PcILsGjKq8O0mm24bw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.4.tgz", + "integrity": "sha512-rT6yQcxUuXs4CnbofqwHRRV0iem349rLMYpTjkgQGLjrY4ado/eDzwPZPTCgTOlF6Nkp8NEv70yLMTn6qkWxsQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.4.tgz", + "integrity": "sha512-KXMGoboq5cyaCQjDA4GLuRiOwBQ0EyFnJoVViLeZ45/3rFItRODEr+NdsBcVpll40hhNArlm/speWGRvj08LzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.4.tgz", + "integrity": "sha512-5K83rb36oJiY7BCyE9zLZtGcPV4g5wvq+xwdO0XPIwDVZI8cyB/AUjkNXGb92/rnmezEkjMOpgY61rtwjQtFwg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.4.tgz", + "integrity": "sha512-PnWBtw3TV5KOg69HQQDR0mnQuyCmSGR2pAB4DC1rPF808fgKeTUMj2EOEyKATpgiuxuR5APQmiDO7PDgEjTFSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.4.tgz", + "integrity": "sha512-M1lpniBePobTfsa7Ks9a199e1akxsXn+GYBUKsEzv3YFzOm1HJAMNwKI3qr0Zq+mxwx9gOZoTdP1yXRYsZUocQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/chrome": { "version": "0.0.313", "dev": true, @@ -477,6 +854,13 @@ "compression-webpack-plugin": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/eslint": { "version": "9.6.1", "dev": true, @@ -542,9 +926,14 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "18.6.3", + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", + "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } }, "node_modules/@types/pako": { "version": "2.0.3", @@ -750,6 +1139,119 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@vitest/expect": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", + "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", + "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.10", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", + "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", + "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.10", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", + "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "@vitest/utils": "4.1.10", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", + "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", + "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.10", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "dev": true, @@ -1037,6 +1539,16 @@ "dev": true, "license": "Python-2.0" }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -1181,6 +1693,16 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "dev": true, @@ -1345,6 +1867,13 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/copy-webpack-plugin": { "version": "13.0.0", "dev": true, @@ -1515,6 +2044,16 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/dot-case": { "version": "3.0.4", "dev": true, @@ -1882,6 +2421,16 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, "node_modules/esutils": { "version": "2.0.3", "dev": true, @@ -1898,6 +2447,16 @@ "node": ">=0.8.x" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, @@ -2099,6 +2658,21 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "dev": true, @@ -2443,53 +3017,326 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "0.4.1", + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, - "node_modules/json5": { - "version": "2.2.3", + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=6" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/keyv": { - "version": "4.5.4", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "json-buffer": "3.0.1" + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/kind-of": { - "version": "6.0.3", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/levn": { - "version": "0.4.1", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], "engines": { - "node": ">= 0.8.0" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lit": { @@ -2597,6 +3444,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, @@ -2737,7 +3594,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "dev": true, "funding": [ { @@ -2785,6 +3644,20 @@ "node": ">=0.10.0" } }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -2920,6 +3793,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "dev": true, @@ -2948,7 +3828,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", "dev": true, "funding": [ { @@ -2966,7 +3848,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3177,6 +4059,40 @@ "node": ">=0.10.0" } }, + "node_modules/rolldown": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.4.tgz", + "integrity": "sha512-IjZYiLxZwpnhwhdBH2ugdTGVSdhCQUmLxLoqyjiL0JxYjyRst+5a0P3xfrTxJ5F638j4Mvvw5FAX5XE6eHpXbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.138.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.4", + "@rolldown/binding-darwin-arm64": "1.1.4", + "@rolldown/binding-darwin-x64": "1.1.4", + "@rolldown/binding-freebsd-x64": "1.1.4", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.4", + "@rolldown/binding-linux-arm64-gnu": "1.1.4", + "@rolldown/binding-linux-arm64-musl": "1.1.4", + "@rolldown/binding-linux-ppc64-gnu": "1.1.4", + "@rolldown/binding-linux-s390x-gnu": "1.1.4", + "@rolldown/binding-linux-x64-gnu": "1.1.4", + "@rolldown/binding-linux-x64-musl": "1.1.4", + "@rolldown/binding-openharmony-arm64": "1.1.4", + "@rolldown/binding-wasm32-wasi": "1.1.4", + "@rolldown/binding-win32-arm64-msvc": "1.1.4", + "@rolldown/binding-win32-x64-msvc": "1.1.4" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "dev": true, @@ -3331,6 +4247,13 @@ "node": ">=8" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "dev": true, @@ -3367,6 +4290,20 @@ "source-map": "^0.6.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "5.1.2", "dev": true, @@ -3598,13 +4535,32 @@ "dev": true, "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { - "version": "0.2.12", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { - "fdir": "^6.4.3", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3614,9 +4570,14 @@ } }, "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.4.3", + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "dev": true, "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, "peerDependencies": { "picomatch": "^3 || ^4" }, @@ -3627,7 +4588,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", "dev": true, "license": "MIT", "engines": { @@ -3637,6 +4600,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "dev": true, @@ -3714,6 +4687,13 @@ "node": ">=14.17" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.1.3", "dev": true, @@ -3756,6 +4736,207 @@ "dev": true, "license": "MIT" }, + "node_modules/vite": { + "version": "8.1.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", + "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.16", + "rolldown": "~1.1.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", + "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.10", + "@vitest/mocker": "4.1.10", + "@vitest/pretty-format": "4.1.10", + "@vitest/runner": "4.1.10", + "@vitest/snapshot": "4.1.10", + "@vitest/spy": "4.1.10", + "@vitest/utils": "4.1.10", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.10", + "@vitest/browser-preview": "4.1.10", + "@vitest/browser-webdriverio": "4.1.10", + "@vitest/coverage-istanbul": "4.1.10", + "@vitest/coverage-v8": "4.1.10", + "@vitest/ui": "4.1.10", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/es-module-lexer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.0.tgz", + "integrity": "sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/watchpack": { "version": "2.4.2", "dev": true, @@ -3946,6 +5127,23 @@ "node": ">= 8" } }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wildcard": { "version": "2.0.1", "dev": true, diff --git a/package.json b/package.json index e42c8f9c..dedb27f6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,8 @@ "build_ff": "webpack --env mode=prod browser=firefox --config webpack.config.js --stats-error-details", "start": "webpack --env mode=development browser=chrome --config webpack.config.js --stats-error-details --watch", "start_ff": "webpack --env mode=development browser=firefox --config webpack.config.js --stats-error-details --watch", + "test": "vitest run", + "test:watch": "vitest", "lint": "eslint .", "format": "prettier --ignore-path .lintignore --write \"**/*.+(js|ts|json)\"", "checkformat": "prettier --ignore-path .lintignore --check \"**/*.+(js|ts|json)\"", @@ -64,6 +66,7 @@ "sass-loader": "^16.0.5", "ts-loader": "^9.5.2", "typescript": "^5.8.3", + "vitest": "^4.1.10", "webpack": "^5.98.0", "webpack-cli": "^6.0.1" }, diff --git a/tsconfig.json b/tsconfig.json index 0a707330..e121c0b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,5 +13,5 @@ "moduleResolution": "Node", "skipLibCheck": true }, - "exclude": ["dist/", "tools/"] + "exclude": ["dist/", "tools/", "**/*.test.ts", "vitest.config.ts"] } diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..75e3ae15 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,7 @@ +import {defineConfig} from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + }, +}); From bafdfa1128d427ee04b73a641df21e25590c9876 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 7 Jul 2026 17:39:15 +0200 Subject: [PATCH 6/8] add tests for AST parser --- src/lib/filter/compile_expression.test.ts | 131 ++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 src/lib/filter/compile_expression.test.ts diff --git a/src/lib/filter/compile_expression.test.ts b/src/lib/filter/compile_expression.test.ts new file mode 100644 index 00000000..7b924c80 --- /dev/null +++ b/src/lib/filter/compile_expression.test.ts @@ -0,0 +1,131 @@ +import {describe, expect, it} from 'vitest'; +import {compileExpression, type FilterValue} from './compile_expression'; +import type {InternalInputVars} from './types'; + +const VARS: InternalInputVars = { + float: 0.2, + seed: 555, + minfloat: 0, + maxfloat: 1, + minwearfloat: 0, + maxwearfloat: 1, + phase: 'Ruby', + low_rank: 10, + high_rank: 90, + price: 100, + pattern: 387, +}; + +/** Compile and run in one step against {@link VARS} (or an override). */ +function run(expression: string, vars: Partial = {}): FilterValue { + return compileExpression(expression)({...VARS, ...vars}); +} + +describe('literals and variables', () => { + it('reads numbers, strings, and booleans', () => { + expect(run('42')).toBe(42); + expect(run('.5')).toBe(0.5); + expect(run('1e3')).toBe(1000); + expect(run("'hello'")).toBe('hello'); + expect(run('true')).toBe(true); + expect(run('false')).toBe(false); + }); + + it('resolves variables', () => { + expect(run('float')).toBe(0.2); + expect(run('phase')).toBe('Ruby'); + }); + + it('returns an error for unknown/undefined variables', () => { + expect(run('nope')).toBeInstanceOf(Error); + expect(run('price', {price: undefined})).toBeInstanceOf(Error); + }); +}); + +describe('arithmetic', () => { + it('respects precedence and associativity', () => { + expect(run('1 + 2 * 3')).toBe(7); + expect(run('(1 + 2) * 3')).toBe(9); + expect(run('2 ^ 3 ^ 2')).toBe(512); // right-associative + expect(run('-2 ^ 2')).toBe(4); // unary binds tighter than ^ + expect(run('7 % 3')).toBe(1); + }); + + it('overloads + for string concatenation', () => { + expect(run("'a' + 'b'")).toBe('ab'); + expect(run("'x' + 1")).toBe('x1'); + }); + + it('rejects arithmetic on non-numbers', () => { + expect(run("'a' - 1")).toBeInstanceOf(Error); + }); +}); + +describe('comparisons and equality', () => { + it('compares numbers and strings', () => { + expect(run('float < 0.5')).toBe(true); + expect(run('float >= 0.2')).toBe(true); + expect(run("'a' < 'b'")).toBe(true); + }); + + it('uses strict equality with no coercion', () => { + expect(run('5 == 5')).toBe(true); + expect(run("5 == '5'")).toBe(false); + expect(run('5 != 6')).toBe(true); + expect(run('5 = 5')).toBe(true); // = is an alias for == + }); + + it('errors when comparing across types', () => { + expect(run("1 < 'a'")).toBeInstanceOf(Error); + }); +}); + +describe('logical operators', () => { + it('evaluates and/or/not', () => { + expect(run('true and false')).toBe(false); + expect(run('true or false')).toBe(true); + expect(run('not false')).toBe(true); + }); + + it('short-circuits so the right side is not required to be valid', () => { + expect(run('false and nope')).toBe(false); + expect(run('true or nope')).toBe(true); + }); +}); + +describe('membership', () => { + it('handles in and not in', () => { + expect(run('seed in (1, 555, 999)')).toBe(true); + expect(run('seed not in (1, 2, 3)')).toBe(true); + expect(run("phase in ('Emerald', 'Ruby')")).toBe(true); + }); +}); + +describe('functions', () => { + it('supports built-in math functions', () => { + expect(run('abs(-3)')).toBe(3); + expect(run('max(1, 5, 2)')).toBe(5); + expect(run('round(1.6)')).toBe(2); + }); + + it('supports injected extra functions', () => { + const runner = compileExpression('double(seed)', { + extraFunctions: {double: (n: number) => n * 2}, + }); + expect(runner(VARS)).toBe(1110); + }); + + it('errors on unknown functions', () => { + expect(run('bogus(1)')).toBeInstanceOf(Error); + }); +}); + +describe('error handling', () => { + it('never throws, returning an Error for malformed input', () => { + expect(() => compileExpression('1 +')).not.toThrow(); + expect(run('1 +')).toBeInstanceOf(Error); + expect(run('(1 + 2')).toBeInstanceOf(Error); + expect(run("'unterminated")).toBeInstanceOf(Error); + expect(run('1 @ 2')).toBeInstanceOf(Error); + }); +}); From e8b068d4ea60a42194cf00dd1927d38eb5d97852 Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 7 Jul 2026 17:58:46 +0200 Subject: [PATCH 7/8] preserve original card styling --- src/lib/components/market/react/highlight.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/components/market/react/highlight.ts b/src/lib/components/market/react/highlight.ts index 7fba5bc2..48f24d04 100644 --- a/src/lib/components/market/react/highlight.ts +++ b/src/lib/components/market/react/highlight.ts @@ -16,8 +16,14 @@ export class ReactListingHighlight extends FloatElement { private filterSubscription?: Subscription; + private originalStyle: {backgroundColor: string; color: string} = {backgroundColor: '', color: ''}; + connectedCallback(): void { super.connectedCallback(); + const card = this.parentElement; + if (card) { + this.originalStyle = {backgroundColor: card.style.backgroundColor, color: card.style.color}; + } this.filterSubscription = gFilterService.onUpdate$.subscribe(() => this.applyColour()); } @@ -38,9 +44,9 @@ export class ReactListingHighlight extends FloatElement { const context = this.injectionContext; if (!card || !context) return; - const colour = gFilterService.matchColour(context.itemInfo, this.convertedPrice) || ''; - card.style.backgroundColor = colour; - card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : ''; + const colour = gFilterService.matchColour(context.itemInfo, this.convertedPrice); + card.style.backgroundColor = colour ?? this.originalStyle.backgroundColor; + card.style.color = colour ? pickTextColour(colour, '#8F98A0', '#484848') : this.originalStyle.color; } protected render() { From 4eb72e3ccd3bfaa5e34655d138e79fb83197609e Mon Sep 17 00:00:00 2001 From: GODrums Date: Tue, 7 Jul 2026 18:01:36 +0200 Subject: [PATCH 8/8] throw on empty list input --- src/lib/filter/compile_expression.test.ts | 4 ++++ src/lib/filter/compile_expression.ts | 12 +++++------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/lib/filter/compile_expression.test.ts b/src/lib/filter/compile_expression.test.ts index 7b924c80..455f45ed 100644 --- a/src/lib/filter/compile_expression.test.ts +++ b/src/lib/filter/compile_expression.test.ts @@ -99,6 +99,10 @@ describe('membership', () => { expect(run('seed not in (1, 2, 3)')).toBe(true); expect(run("phase in ('Emerald', 'Ruby')")).toBe(true); }); + + it('rejects an empty list', () => { + expect(run('seed in ()')).toBeInstanceOf(Error); + }); }); describe('functions', () => { diff --git a/src/lib/filter/compile_expression.ts b/src/lib/filter/compile_expression.ts index 1cfabb80..e767f98a 100644 --- a/src/lib/filter/compile_expression.ts +++ b/src/lib/filter/compile_expression.ts @@ -433,18 +433,16 @@ class Parser { throw new Error(`unexpected token "${token.value}" at ${token.position}`); } - /** Parses the `(...)` operand of `in` / `not in` into a list node. Allows an empty list `()`. */ + /** Parses the `(...)` operand of `in` / `not in` into a list node. Requires at least one element. */ private parseList(): AstNode { this.expect('paren', '('); const values: AstNode[] = []; - if (!this.match('paren', ')')) { - do { - values.push(this.parseOr()); - } while (this.match('comma')); + do { + values.push(this.parseOr()); + } while (this.match('comma')); - this.expect('paren', ')'); - } + this.expect('paren', ')'); return {type: 'list', values}; }