diff --git a/.cursor/rules/style-guide.mdc b/.cursor/rules/style-guide.mdc new file mode 100644 index 00000000..9828a3af --- /dev/null +++ b/.cursor/rules/style-guide.mdc @@ -0,0 +1,319 @@ +--- +description: +globs: +alwaysApply: true +--- +# Style Guide + +Core coding standards and style guidelines for our TypeScript codebase. These guidelines ensure consistency, maintainability, and high code quality across the project. + +## Core Principles +- **KISS (Keep It Simple, Stupid)** - Always choose the simplest, most maintainable solution +- **TypeScript First** - Always use TypeScript with strict typing (`strict: true`) everywhere +- **Less is More** - Always avoid unnecessary complexity, the best code is no code +- **Self-Documenting** - Always make code obvious and clear without comments + +## File Organization + +### Directory Structure +- Always organize code in a predictable and scalable way +- Always keep related code close together +- Always use clear, descriptive directory names +- Always follow consistent patterns across the project +- Always use singular for categories/domains (e.g., `auth/`, `user/`, `product/`) +- Always use plural for collections/lists (e.g., `components/`, `hooks/`, `utils/`) + +✅ Good: +```typescript +src/ + auth/ # Singular: domain + components/ # Plural: collection + hooks/ # Plural: collection + lib/ # Singular: category + + user/ # Singular: domain + components/ # Plural: collection + hooks/ # Plural: collection + lib/ # Singular: category + + lib/ # Singular: core category + components/ # Plural: shared collection + hooks/ # Plural: shared collection +``` + +❌ Bad: +```typescript +src/ + auths/ # Wrong: Category should be singular + component/ # Wrong: Collection should be plural + + users/ # Wrong: Category should be singular + hook/ # Wrong: Collection should be plural + + libraries/ # Wrong: Category should be singular + shared-components/ # Wrong: Use simple plural +``` + +### Files & Directories +- Always use consistent and predictable naming patterns +- Always make names descriptive and purpose-indicating +- Always follow established community conventions + +✅ Good: +```typescript +// Directories (kebab-case) +src/ + auth/ + components/ + hooks/ + lib/ + +// Regular Files (kebab-case) +user-service.ts +jwt-utils.ts +date-formatter.ts +api-client.ts + +// Component Files (PascalCase) +UserProfileCard.tsx +OrderSummaryTable.tsx +PaymentMethodSelector.tsx +ButtonPrimary.tsx + +// Class Files (PascalCase) +OrderProcessor.ts +PaymentGateway.ts +CacheManager.ts +``` + +❌ Bad: +```typescript +// Directories (mixed case) +src/ + UserManagement/ # Wrong: PascalCase directory + order_processing/ # Wrong: snake_case directory + PAYMENT/ # Wrong: UPPERCASE directory + Shared-Utils/ # Wrong: Mixed kebab-case and PascalCase + +// Files (inconsistent) +userService.ts # Wrong: camelCase +USER_HELPERS.ts # Wrong: SNAKE_CASE +payment.utilities.ts # Wrong: dot notation +Api.Client.ts # Wrong: PascalCase with dots +``` + +### Code Identifiers +- Always use clear, descriptive names that indicate purpose +- Always follow TypeScript community standards +- Always maintain consistent prefixing for special types + +✅ Good: +```typescript +// Variables & Functions (camelCase) +const currentUser = getCurrentUser(); +const isValidEmail = validateEmail(email); +const calculateTotalPrice = (items: TOrderItem[]): number => { + return items.reduce((sum, item) => sum + item.price, 0); +}; + +// Interfaces & Types (T prefix) +type TUser = { + id: string; + email: string; + profile: TUserProfile; +}; + +type TOrderItem = { + id: string; + productId: string; + quantity: number; + price: number; +}; + +// Enums (E prefix) +enum EOrderStatus { + Pending = 'pending', + Processing = 'processing', + Completed = 'completed', + Cancelled = 'cancelled' +} + +enum EUserRole { + Admin = 'admin', + Customer = 'customer', + Guest = 'guest' +} + +// Schemas (S prefix) +const SUserProfile = z.object({ + firstName: z.string().min(2), + lastName: z.string().min(2), + dateOfBirth: z.date().optional(), + phoneNumber: z.string().regex(/^\+?[1-9]\d{1,14}$/).optional() +}); + +const SOrderCreate = z.object({ + userId: z.string().uuid(), + items: z.array(z.object({ + productId: z.string().uuid(), + quantity: z.number().int().positive() + })) +}); +``` + +❌ Bad: +```typescript +// Variables & Functions (inconsistent) +const CurrentUser = getCurrentUser(); # Wrong: PascalCase +const valid_email = validate_email(); # Wrong: snake_case +const CALCULATE_PRICE = () => {}; # Wrong: UPPER_CASE + +// Types & Interfaces (missing prefix) +type User = { # Wrong: Missing T prefix + ID: string; # Wrong: UPPER_CASE + Email: string; # Wrong: PascalCase +}; + +interface OrderItem { # Wrong: Missing T prefix + product_id: string; # Wrong: snake_case + Quantity: number; # Wrong: PascalCase +} + +// Enums (inconsistent) +enum OrderStatus { # Wrong: Missing E prefix + PENDING = 'PENDING', # Wrong: All caps + Processing = 'Processing', # Wrong: PascalCase value + completed = 'completed' # Wrong: camelCase +} + +// Schemas (inconsistent) +const userSchema = z.object({ # Wrong: Missing S prefix + FirstName: z.string(), # Wrong: PascalCase + last_name: z.string(), # Wrong: snake_case + DOB: z.date() # Wrong: Abbreviation +}); +``` + +## Code Style + +### Type Safety +- Always define explicit types for better maintainability +- Always use TypeScript's type system to prevent runtime errors +- Always make code intentions clear through typing + +✅ Good: +```typescript +async function getUser(id: string): Promise { + const user = await db.users.findUnique({ where: { id } }); + if (user == null) { + throw new Error('User not found'); + } + return user; +} +``` + +❌ Bad: +```typescript +async function getUser(id) { + const user = await db.users.findUnique({ where: { id } }); + if (!user) throw new Error('User not found'); + return user; +} +``` + +### Null Checks +- Always be explicit about null/undefined checks +- Always handle edge cases clearly and consistently +- Always prevent runtime null/undefined errors + +✅ Good: +```typescript +if (user == null) { + throw new Error('User is required'); +} + +const name = user.name ?? 'Anonymous'; +``` + +❌ Bad: +```typescript +if (!user) { + throw new Error('User is required'); +} + +const name = user.name || 'Anonymous'; +``` + +### Functions +- Always keep functions focused and single-purpose +- Always use function declarations for named functions +- Always use arrow functions only for callbacks and inline functions + +✅ Good: +```typescript +function processUser(user: TUser): void { + // Implementation +} + +users.map(user => user.name); +``` + +❌ Bad: +```typescript +const processUser = (user: TUser): void => { + // Implementation +} + +users.map(function(user) { return user.name; }); +``` + +### Conditionals +- Always keep conditionals simple and flat +- Always use early returns to reduce nesting +- Always avoid deeply nested conditions + +✅ Good: +```typescript +// Early return pattern +function processUser(user: TUser): void { + if (user == null) { + return; + } + + if (!user.isActive) { + return; + } + + processActiveUser(user); +} + +// Simple boolean check +function isValidUser(user: TUser): boolean { + return user != null && user.isActive; +} +``` + +❌ Bad: +```typescript +// Deeply nested conditions +function processUser(user: TUser): void { + if (user != null) { + if (user.isActive) { + if (user.permissions != null) { + if (user.permissions.canEdit) { + processActiveUser(user); + } + } + } + } +} + +// Complex nested ternary +const userName = user + ? user.profile + ? user.profile.name + ? user.profile.name + : 'No name' + : 'No profile' + : 'No user'; +``` \ No newline at end of file diff --git a/README.md b/README.md index a281f613..368cb22b 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ A collection of open source libraries maintained by [builder.group](https://buil | [config](https://github.com/builder-group/community/blob/develop/packages/config) | Collection of ESLint, Vite, and Typescript configurations | [`@blgc/config`](https://www.npmjs.com/package/@blgc/config) | | [elevenlabs-client](https://github.com/builder-group/community/blob/develop/packages/elevenlabs-client) | Typesafe and straightforward fetch client for interacting with the ElevenLabs API using feature-fetch | [`elevenlabs-client`](https://www.npmjs.com/package/elevenlabs-client) | | [eprel-client](https://github.com/builder-group/community/blob/develop/packages/eprel-client) | Typesafe and straightforward fetch client for interacting with the European Product Registry for Energy Labelling (EPREL) API using feature-fetch | [`eprel-client`](https://www.npmjs.com/package/eprel-client) | +| [feature-ecs](https://github.com/builder-group/community/blob/develop/packages/feature-ecs) | A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript | [`feature-ecs`](https://www.npmjs.com/package/feature-ecs) | | [feature-fetch](https://github.com/builder-group/community/blob/develop/packages/feature-fetch) | Straightforward, typesafe, and feature-based fetch wrapper supporting OpenAPI types | [`feature-fetch`](https://www.npmjs.com/package/feature-fetch) | | [feature-form](https://github.com/builder-group/community/blob/develop/packages/feature-form) | Straightforward, typesafe, and feature-based form library | [`feature-form`](https://www.npmjs.com/package/feature-form) | | [feature-logger](https://github.com/builder-group/community/blob/develop/packages/feature-logger) | Straightforward, typesafe, and feature-based logging library | [`feature-logger`](https://www.npmjs.com/package/feature-logger) | @@ -27,6 +28,7 @@ A collection of open source libraries maintained by [builder.group](https://buil | [feature-state](https://github.com/builder-group/community/blob/develop/packages/feature-state) | Straightforward, typesafe, and feature-based state management library for ReactJs | [`feature-state`](https://www.npmjs.com/package/feature-state) | | [figma-connect](https://github.com/builder-group/community/blob/develop/packages/figma-connect) | Straightforward and typesafe wrapper around the communication between the app/ui (iframe) and plugin (sandbox) part of a Figma Plugin | [`figma-connect`](https://www.npmjs.com/package/figma-connect) | | [google-webfonts-client](https://github.com/builder-group/community/blob/develop/packages/google-webfonts-client) | Typesafe and straightforward fetch client for interacting with the Google Web Fonts API using feature-fetch | [`google-webfonts-client`](https://www.npmjs.com/package/google-webfonts-client) | +| [head-metadata](https://github.com/builder-group/community/blob/develop/packages/head-metadata) | Typesafe and straightforward utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document. | [`head-metadata`](https://www.npmjs.com/package/head-metadata) | | [openapi-ts-router](https://github.com/builder-group/community/blob/develop/packages/openapi-ts-router) | Thin wrapper around the router of web frameworks like Express and Hono, offering OpenAPI typesafety and seamless integration with validation libraries such as Valibot and Zod | [`openapi-ts-router`](https://www.npmjs.com/package/openapi-ts-router) | | [rollup-presets](https://github.com/builder-group/community/blob/develop/packages/rollup-presets) | A collection of opinionated, production-ready Rollup presets | [`rollup-presets`](https://www.npmjs.com/package/rollup-presets) | | [types](https://github.com/builder-group/community/blob/develop/packages/types) | Shared TypeScript type definitions used across builder.group community packages | [`@blgc/types`](https://www.npmjs.com/package/@blgc/types) | @@ -119,4 +121,4 @@ In short, we use objects because they are more flexible and allow for the kind o ### What Features? -Think of features like components in an Entity Component System (ECS). Every feature based object (e.g., feature-state) has base functionality, and additional components (features) can be added to extend it. Unlike traditional ECS, we only adopt the concept of Components, without Systems or Entities. Each component contains the necessary functions to interact with the feature based object. \ No newline at end of file +Think of features like components in an Entity Component System (ECS). Every feature based object (e.g., feature-state) has base functionality, and additional components (features) can be added to extend it. Unlike traditional ECS, we only adopt the concept of Components, without Systems or Entities. Each component contains the necessary functions to interact with the feature based object. diff --git a/examples/feature-ecs/vanilla/basic/.gitignore b/examples/feature-ecs/vanilla/basic/.gitignore new file mode 100644 index 00000000..a547bf36 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/examples/feature-ecs/vanilla/basic/index.html b/examples/feature-ecs/vanilla/basic/index.html new file mode 100644 index 00000000..44a93350 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/index.html @@ -0,0 +1,13 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/svg+xml" href="/vite.svg" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <title>Vite + TS + + +
+ + + diff --git a/examples/feature-ecs/vanilla/basic/package.json b/examples/feature-ecs/vanilla/basic/package.json new file mode 100644 index 00000000..6fe1b447 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/package.json @@ -0,0 +1,18 @@ +{ + "name": "basic", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc && vite build", + "dev": "vite", + "preview": "vite preview" + }, + "dependencies": { + "feature-ecs": "workspace:*" + }, + "devDependencies": { + "typescript": "~5.8.3", + "vite": "^6.3.5" + } +} diff --git a/examples/feature-ecs/vanilla/basic/public/vite.svg b/examples/feature-ecs/vanilla/basic/public/vite.svg new file mode 100644 index 00000000..e7b8dfb1 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/examples/feature-ecs/vanilla/basic/src/main.ts b/examples/feature-ecs/vanilla/basic/src/main.ts new file mode 100644 index 00000000..d387e2b6 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/main.ts @@ -0,0 +1,116 @@ +import { createWorld, Entity } from 'feature-ecs'; +import './style.css'; + +// Get canvas and context +const canvas = + document.querySelector('#app canvas') || document.createElement('canvas'); +if (canvas.parentElement == null) { + canvas.width = 800; + canvas.height = 600; + document.querySelector('#app')!.innerHTML = ''; + document.querySelector('#app')!.appendChild(canvas); +} +const ctx = canvas.getContext('2d') as CanvasRenderingContext2D; + +// Define components +const Position: { x: number[]; y: number[] } = { x: [], y: [] }; +const Velocity: { dx: number[]; dy: number[] } = { dx: [], dy: [] }; +const Rectangle: { width: number[]; height: number[] } = { width: [], height: [] }; +const Color: { value: string[] } = { value: [] }; + +// Create world +const world = createWorld(); + +// @ts-ignore +// globalThis['__world'] = world; + +// Create entities +for (let i = 0; i < 100; i++) { + const entity = world.createEntity(); + + world.addComponent(entity, Position, { + x: getRandom(canvas.width), + y: getRandom(canvas.height) + }); + world.addComponent(entity, Velocity, { + dx: getRandom(100, 20), + dy: getRandom(100, 20) + }); + world.addComponent(entity, Rectangle, { + width: getRandom(20, 10), + height: getRandom(20, 10) + }); + world.addComponent(entity, Color, { + value: `rgba(${getRandom(255)}, ${getRandom(255)}, ${getRandom(255)}, 1)` + }); +} + +// Physics system +function physicsSystem(dt: number): void { + for (const [eid, pos, vel, rect] of world.queryComponents([ + Entity, + Position, + Velocity, + Rectangle + ] as const)) { + // Move position + pos.x += vel.dx * dt; + pos.y += vel.dy * dt; + + // Boundary collision + if (pos.x + rect.width > canvas.width) { + pos.x = canvas.width - rect.width; + vel.dx = -vel.dx; + } else if (pos.x < 0) { + pos.x = 0; + vel.dx = -vel.dx; + } + + if (pos.y + rect.height > canvas.height) { + pos.y = canvas.height - rect.height; + vel.dy = -vel.dy; + } else if (pos.y < 0) { + pos.y = 0; + vel.dy = -vel.dy; + } + + // Update components + world.updateComponent(eid, Position, pos, false); + world.updateComponent(eid, Velocity, vel, false); + } +} + +// Rendering system +function renderingSystem(): void { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const [pos, color, rect] of world.queryComponents([Position, Color, Rectangle] as const)) { + ctx.fillStyle = color.value; + ctx.fillRect(pos.x, pos.y, rect.width, rect.height); + } +} + +// Game loop +let lastTime = 0; + +function gameLoop(currentTime: number): void { + const dt = (currentTime - lastTime) / 1000; + lastTime = currentTime; + + // Run systems + physicsSystem(dt); + renderingSystem(); + + // Clear change tracking + world.flush(); + + requestAnimationFrame(gameLoop); +} + +// Start game +requestAnimationFrame(gameLoop); + +// Helper function +function getRandom(max: number, min = 0): number { + return Math.floor(Math.random() * (max - min)) + min; +} diff --git a/examples/feature-ecs/vanilla/basic/src/style.css b/examples/feature-ecs/vanilla/basic/src/style.css new file mode 100644 index 00000000..62d7d443 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/style.css @@ -0,0 +1,14 @@ +:root { + background-color: #242424; + color: rgba(255, 255, 255, 0.87); + + color-scheme: light dark; + font-weight: 400; + line-height: 1.5; + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} diff --git a/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts b/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/examples/feature-ecs/vanilla/basic/tsconfig.json b/examples/feature-ecs/vanilla/basic/tsconfig.json new file mode 100644 index 00000000..1eec35be --- /dev/null +++ b/examples/feature-ecs/vanilla/basic/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/packages/_deprecated/kleinanzeigen-client/README.md b/packages/_deprecated/kleinanzeigen-client/README.md new file mode 100644 index 00000000..258cd572 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/README.md @@ -0,0 +1 @@ +todo diff --git a/packages/_deprecated/kleinanzeigen-client/eslint.config.js b/packages/_deprecated/kleinanzeigen-client/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/_deprecated/kleinanzeigen-client/package.json b/packages/_deprecated/kleinanzeigen-client/package.json new file mode 100644 index 00000000..74da270d --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/package.json @@ -0,0 +1,52 @@ +{ + "name": "kleinanzeigen-client", + "version": "0.0.1", + "private": false, + "description": "Typesafe and straightforward fetch client for interacting with the Kleinanzeigen API using feature-fetch", + "keywords": [], + "homepage": "https://builder.group/?source=package-json", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "openapi:generate": "npx openapi-typescript ./resources/openapi-v1.yaml -o ./src/gen/v1.ts", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "feature-fetch": "workspace:*", + "xml-tokenizer": "workspace:*" + }, + "devDependencies": { + "@blgc/config": "workspace:*", + "@types/node": "^22.15.21", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/_deprecated/kleinanzeigen-client/rollup.config.js b/packages/_deprecated/kleinanzeigen-client/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/_deprecated/kleinanzeigen-client/src/__tests__/playground.test.ts b/packages/_deprecated/kleinanzeigen-client/src/__tests__/playground.test.ts new file mode 100644 index 00000000..1292e494 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/__tests__/playground.test.ts @@ -0,0 +1,39 @@ +import fs from 'fs'; +import path from 'path'; +import { describe, expect, it } from 'vitest'; +import { extractAdsData } from '../extract-ads'; +import { fetchAds } from '../fetch-ads'; + +describe('playground', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + + describe('should work', () => { + it('should fetch ads with basic search', async () => { + const result = await fetchAds({ + query: 'laptop' + }); + + expect(result).toBeDefined(); + expect(result.html).toBeDefined(); + + fs.writeFileSync( + path.resolve(__dirname, './resources/e2e/s-laptop.html'), + result.html, + 'utf-8' + ); + }); + + it('should extract complete listing data', () => { + const html = fs.readFileSync( + path.resolve(__dirname, './resources/e2e/s-laptop.html'), + 'utf-8' + ); + + const listings = extractAdsData(html); + + console.log('Extracted listings:', JSON.stringify(listings, null, 2)); + }); + }); +}); diff --git a/packages/_deprecated/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html b/packages/_deprecated/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html new file mode 100644 index 00000000..7dc75889 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/__tests__/resources/e2e/s-laptop.html @@ -0,0 +1,7700 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Laptop kleinanzeigen.de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+
+ +

1 - 25 von 122.854 Ergebnissen für „laptop“ in Deutschland +

+
+ +
+ + + + Sortieren nach: + + + + + + + + + + + +
+
Neueste
+ +
    + + + + + + + +
  • + Neueste +
  • + + + + + + + +
  • + Niedrigster Preis +
  • + + + + + + + +
  • + Höchster Preis +
  • + +
+
+
+
+ +
+
+
+ + + + + + + + + + +
+ + + + +
+ +
+
+

Kategorien

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Elektronik + + + +  (94.673) + + + + + + +
      + +
    • + + + + + + + + + + + + + + + + + + + Notebooks + + + +  (79.564) + + + + + +
    • + +
    • + + + + + + + + + + + + + + + + + + + PC-Zubehör & Software + + + +  (12.093) + + + + + +
    • + + +
    • mehr
    • + +
    + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mode & Beauty + + + +  (20.251) + + + + + + + + +
  • + + +
  • Alle Kategorien
  • + +
+
+
+
+
+

Zustand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Neu + + + +  (6.358) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sehr Gut + + + +  (36.942) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gut + + + +  (18.972) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + In Ordnung + + + +  (4.437) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Defekt + + + +  (3.745) + + + + + +
  • + + +
+
+
+
+
+

Versand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (63.696) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (15.480) + + + + + +
  • + + +
+
+
+
+
+

Preis

+
+
+
+
+ -
+ + +
+
+
+
+
+

Angebotstyp

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Angebote + + + +  (122.024) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gesuche + + + +  (830) + + + + + +
  • + + +
+
+
+
+
+

Anbieter

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Privat + + + +  (116.652) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gewerblich + + + +  (6.202) + + + + + +
  • + + +
+
+
+
+
+

Direkt kaufen

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Aktiv + + + +  (39.293) + + + + + +
  • + + +
+
+
+
+
+

Versand

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (98.041) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (23.426) + + + + + +
  • + + +
+
+
+
+
+

Paketdienst

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + DHL + + + +  (77.039) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hermes + + + +  (68.438) + + + + + +
  • + + +
+
+
+
+
+

Ort

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Baden-Württemberg + + + +  (16.188) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bayern + + + +  (21.134) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Berlin + + + +  (7.918) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Brandenburg + + + +  (2.565) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bremen + + + +  (1.097) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hamburg + + + +  (4.042) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hessen + + + +  (9.927) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mecklenburg-Vorpommern + + + +  (1.350) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Niedersachsen + + + +  (11.495) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nordrhein-Westfalen + + + +  (26.802) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Rheinland-Pfalz + + + +  (5.259) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Saarland + + + +  (1.200) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen + + + +  (5.158) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen-Anhalt + + + +  (1.902) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Schleswig-Holstein + + + +  (4.792) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Thüringen + + + +  (2.025) + + + + + +
  • + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Pro (14" 2021) mit Apple M1 Pro Chip Nordrhein-Westfalen - Velbert Vorschau + + + +
    + 2 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 42553 Velbert + + + +
    +
    + +
    +
    +
    +

    + + + + Apple MacBook Pro (14" 2021) mit Apple M1 Pro Chip + + +

    +

    Macbook Pro M1 Pro Chip in gutem Zustand . Es hat 32 GB Arbeitsspeicher und eine Festplatte der...

    + +
    +

    + 1.300 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo ThinkPad P14s Gen 2 – 32GB RAM | 1TB SSD | Top Zustand Friedrichshain-Kreuzberg - Friedrichshain Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10249 Friedrichshain + + + +
    +
    + +
    +
    +
    +

    + + + + Lenovo ThinkPad P14s Gen 2 – 32GB RAM | 1TB SSD | Top Zustand + + +

    +

    Deutsch + +Ich verkaufe mein Lenovo ThinkPad P14s Gen 2 – in ausgezeichnetem Zustand. Ideal für...

    + +
    +

    + 740 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + +
    + + + + + +
    +
    + + + + +
    +
    +
    +
    + + 85077 Manching + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + Laptop für kleine Kinder + + + +

    +

    Laptop mit verschiedenen Spielen

    + +
    +

    + 25 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Asus Notebook 16 Zoll mit Laufwerk und Ziffernblock Nordrhein-Westfalen - Hagen Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58119 Hagen + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + + Asus Notebook 16 Zoll mit Laufwerk und Ziffernblock + + +

    +

    Angeboten wird hier ein sehr alter, aber zuverlässiger Laptop. Er hat deutliche Gebrauchsspuren,...

    + +
    +

    + 1 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + 2 Dell Laptops Latitude E7440 Mecklenburg-Vorpommern - Ducherow Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 17398 Ducherow + + + +
    +
    + + + Heute, 10:19 + +
    +
    +
    +

    + + + + 2 Dell Laptops Latitude E7440 + + +

    +

    120 GB , i5-4300u, 8 GB, er iner hat win 11, einer win 10, die Oberfläche bei der Handauflage des...

    + +
    +

    + 130 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptoptasche Friedrichshain-Kreuzberg - Friedrichshain Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10243 Friedrichshain + + + +
    +
    + + + Heute, 10:18 + +
    +
    +
    +

    + + + + Laptoptasche + + +

    +

    Laptoptasche +Höhe 35cm, Länge 40cm +Passend bis zu 19 Zoll + +Der Verkauf erfolgt unter Ausschluss...

    + +
    +

    + 14 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + HP RTL8723BE Laptop teildefekt Sachsen - Görlitz Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 02826 Görlitz + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + HP RTL8723BE Laptop teildefekt + + +

    +

    Hallo + +verkaufe diesen Laptop teildefekt . Wie man sieht auf den Bildern sind , Na ja wie zwei...

    + +
    +

    + 60 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Tablet Laptop 2in1 Microsoft Surface Pro 5 128 GB Set Tastatur Nordrhein-Westfalen - Düren Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 52353 Düren + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + Tablet Laptop 2in1 Microsoft Surface Pro 5 128 GB Set Tastatur + + +

    +

    guter, gebrauchter Zustand, keine Kratzer auf Display, voll funktionsfähig + +Surface Pro 1796 (5....

    + +
    +

    + 189 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Air 13” (2019) – Top Zustand Nordrhein-Westfalen - Remscheid Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 42853 Remscheid + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + MacBook Air 13” (2019) – Top Zustand + + +

    +

    Apple MacBook Air 13” (2019) – Top Zustand – 128 GB SSD, i5, 8 GB RAM + +Ich biete hier ein Apple...

    + +
    +

    + 340 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + tpplet und Boxen Nürnberg (Mittelfr) - Südstadt Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 90441 Südstadt + + + +
    +
    + + + Heute, 10:17 + +
    +
    +
    +

    + + + + tpplet und Boxen + + +

    +

    tepplet mit Boxen, guter Zustand , privat verkauf vom Umtausch ausgeschlossen,

    + +
    +

    + 100 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + HP ProBook 650 G5, 15,6" FHD, intel Core i5-8265U, 8GB DDR4, 256G Berlin - Tempelhof Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12305 Tempelhof + + + +
    +
    + + + Heute, 10:16 + +
    +
    +
    +

    + + + + HP ProBook 650 G5, 15,6" FHD, intel Core i5-8265U, 8GB DDR4, 256G + + +

    +

    30525C + + +Hersteller HP + + +Model ProBook 650...

    + +
    +

    + 140 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop Schoßablage München - Schwabing-West Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80803 Schwabing-​West + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Laptop Schoßablage + + +

    +

    Laptop Schoßablage. Super, um den Laptop im Schoß abzustellen. + +— +Privatverkauf. Keine...

    + +
    +

    + 5 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + 2x 8GB Kingston DDR3L 1600MHz Laptop RAM (16GB) München - Sendling-Westpark Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80686 Sendling-​Westpark + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + 2x 8GB Kingston DDR3L 1600MHz Laptop RAM (16GB) + + +

    +

    Verkaufe zwei gebrauchte, voll funktionsfähige Kingston 8GB DDR3L (PC3L-12800S) RAM-Module

    + +
    +

    + 30 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Flex 15 Sachsen - Taucha Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 04425 Taucha + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Lenovo Flex 15 + + +

    +

    zum verkauf steht ein funktionsfähiges 15 zoll Lenovo Flex +das Gerät stammt aus einer Auflösung...

    + +
    +

    + 79 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook 12” 2017 – Rose Gold  8GB RAM – 256GB Essen - Essen-Stadtmitte Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 45127 Essen-​Stadtmitte + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Apple MacBook 12” 2017 – Rose Gold 8GB RAM – 256GB + + +

    +

    Ich verkaufe mein gut erhaltenes Apple MacBook 12” aus dem Jahr 2017 in der Farbe Rose...

    + +
    +

    + 180 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptoptasche Schleswig-Holstein - Schuby Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 24850 Schuby + + + +
    +
    + + + Heute, 10:15 + +
    +
    +
    +

    + + + + Laptoptasche + + +

    +

    Verschenke diese Laptoptasche der Marke Samsonite + +Standort Lürschau

    + +
    +

    + Zu verschenken + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Wortmann Terra Mobile 1460P 14" FHD, intel Core i5-8200Y, 8GB DDR Berlin - Tempelhof Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12305 Tempelhof + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Wortmann Terra Mobile 1460P 14" FHD, intel Core i5-8200Y, 8GB DDR + + +

    +

    Hersteller Wortmann Terra + +Model Mobile 1460P + + +Zustand gebraucht + +Optisch 2...

    + +
    +

    + 129 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook HP 250 G5 Nürnberg (Mittelfr) - Oststadt Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 90489 Oststadt + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Notebook HP 250 G5 + + +

    +

    Notebook HP 250 G5 +- Intel Pentium N3710 (4x 1,6 GHz) +- 39,6cm 15,6" TFT Display +- 1366 x 768...

    + +
    +

    + 250 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Neue Laptoptasche von Targus Bochum - Bochum-Nord Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 44807 Bochum-​Nord + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Neue Laptoptasche von Targus + + +

    +

    Verkaufe neue Laptoptasche von Targus für max. 15 Zoll Laptop. Die Tasche hat viele Fächer und ist...

    + +
    +

    + 20 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Chromebook von ASUS CX1400CN Thüringen - Saalfeld (Saale) Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 07318 Saalfeld (Saale) + + + +
    +
    + + + Heute, 10:14 + +
    +
    +
    +

    + + + + Chromebook von ASUS CX1400CN + + +

    +

    verkaufe mein chromebook von Asus. Dieser Artikel wahr kaum in Benutzung. Wird aber nicht mehr...

    + +
    +

    + 65 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer 13,3“ Notebook Niedersachsen - Schapen Vorschau + + + +
    + 9 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 48480 Schapen + + + +
    +
    + + + Heute, 10:13 + +
    +
    +
    +

    + + + + Acer 13,3“ Notebook + + +

    +

    Verkaufe einen voll funktionstüchtigen Acer ES1-311 Notebook. Der hat eine 128GB SSD Festplatte und...

    + +
    +

    + 100 € VB + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + laptop  NOTEBOOOK  COMPUTER Essen - Rüttenscheid Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 45130 Rüttenscheid + + + +
    +
    + + + Heute, 10:12 + +
    +
    +
    +

    + + + + laptop NOTEBOOOK COMPUTER + + +

    +

    Ich verkaufe diesen Laptop mit Ladegerät, keine Festplatte

    + +
    +

    + 150 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 13 mit Touchbar Neuwertig 8GB/128GB Dortmund - Körne Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 44141 Körne + + + +
    +
    + + + Heute, 10:12 + +
    +
    +
    +

    + + + + MacBook Pro 13 mit Touchbar Neuwertig 8GB/128GB + + +

    +

    Biete hier mein MacBook Pro mit Touchbar von Ende 2019 zum Verkauf an. + +Das MacBook gehört noch zu...

    + +
    +

    + 450 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Mac Book Pro 2019 I7 1 TB 16 GB RAM Tausch gegen IPad Pro Niedersachsen - Lehrte Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 31275 Lehrte + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + Mac Book Pro 2019 I7 1 TB 16 GB RAM Tausch gegen IPad Pro + + +

    +

    Moin, + +Würde hier mein MacBook Pro verkaufen / Tauschen gegen iPad Pro. + +Mac Book ist ein 2019...

    + +
    +

    + VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MSI Alpha 17 Niedersachsen - Nienburg (Weser) Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 31582 Nienburg (Weser) + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + MSI Alpha 17 + + +

    +

    Verkaufe mein Gaming Notebook von MSI bei Interesse einfach melden.

    + +
    +

    + 900 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Fujitsu Lifebook A3511 (FPC04951BS) - neu und unbenutzt Berlin - Schöneberg Vorschau + + + +
    + 7 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10823 Schöneberg + + + +
    +
    + + + Heute, 10:11 + +
    +
    +
    +

    + + + + Fujitsu Lifebook A3511 (FPC04951BS) - neu und unbenutzt + + +

    +

    Daten: +Prozessor: Intel Core i3 +Arbeitsspeicher: DDR 4 - 8 GB +Displaygröße: 39,62 cm (15,6...

    + +
    +

    + 350 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + gaming laptop Asus ROG strix G13IC Berlin - Tempelhof Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 12103 Tempelhof + + + +
    +
    + + + Heute, 10:10 + +
    +
    +
    +

    + + + + gaming laptop Asus ROG strix G13IC + + +

    +

    Rtx 3050 +ryzen 7 400 +Bildschirm 1920.1080 17zoll +500gb +funktioniert sehr gut

    + +
    +

    + 700 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+

Ähnliche Suchanfragen

+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + diff --git a/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts b/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts new file mode 100644 index 00000000..5813a5d9 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/extract-ads.ts @@ -0,0 +1,75 @@ +import { htmlConfig, pathTracker, process, TProcessor } from 'xml-tokenizer'; + +const articleExtractor: TProcessor< + { + articles: Array<{ id: string; title: string }>; + currentArticle: { id?: string; title?: string } | null; + }, + [typeof pathTracker] +> = { + name: 'ArticleExtractor', + context: { + articles: [], + currentArticle: null + }, + deps: [pathTracker], + process: (token, context) => { + const path = context.currentPath; + + // Start tracking a new article when we enter an article element + if (token.type === 'ElementStart' && token.local === 'article') { + context.currentArticle = {}; + } + + // Extract ID from data-adid attribute on article element + if ( + token.type === 'Attribute' && + token.local === 'data-adid' && + path[path.length - 1] === 'article' && + context.currentArticle + ) { + context.currentArticle.id = token.value; + } + + // Extract title from text in h2/a path within article + if ( + token.type === 'Text' && + path.includes('article') && + path.includes('h2') && + path.includes('a') && + context.currentArticle + ) { + const text = token.text.trim(); + if (text.length > 0) { + context.currentArticle.title = text; + } + } + + // Finish article when we close the article element + if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'article' && + context.currentArticle && + context.currentArticle.id && + context.currentArticle.title + ) { + context.articles.push({ + id: context.currentArticle.id, + title: context.currentArticle.title + }); + context.currentArticle = null; + } + } +}; + +export function extractAdsData(html: string): TListingData[] { + const result = process(html, [pathTracker, articleExtractor], htmlConfig); + + return result.articles; +} + +export type TListingData = { + id: string; + title?: string; +}; diff --git a/packages/_deprecated/kleinanzeigen-client/src/fetch-ads.ts b/packages/_deprecated/kleinanzeigen-client/src/fetch-ads.ts new file mode 100644 index 00000000..ae053bf7 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/fetch-ads.ts @@ -0,0 +1,80 @@ +import { createApiFetchClient } from 'feature-fetch'; + +export async function fetchAds(options: TFetchAds = {}): Promise { + const { + url = 'https://www.kleinanzeigen.de', + query, + location, + radius, + minPrice, + maxPrice, + page = 1 + } = options; + + const fetchClient = createApiFetchClient({ + prefixUrl: url + }); + + // Build path template and path params + let pathTemplate = ''; + const pathParams: Record = {}; + + // Add price filter path + if (minPrice != null || maxPrice != null) { + pathTemplate += '/preis:{minPrice}:{maxPrice}'; + pathParams['minPrice'] = minPrice != null ? minPrice : ''; + pathParams['maxPrice'] = maxPrice != null ? maxPrice : ''; + } + + // Add page path + if (page != null) { + pathTemplate += '/s-seite:{page}'; + pathParams['page'] = page; + } + + // Build query parameters + const queryParams: Record = {}; + if (query) { + queryParams['keywords'] = query; + } + if (location) { + queryParams['locationStr'] = location; + } + if (radius != null) { + queryParams['radius'] = radius; + } + + // Fetch the HTML + const response = await fetchClient.get(pathTemplate, { + pathParams, + queryParams, + parseAs: 'text' + }); + + if (response.isErr()) { + throw new Error(`Failed to fetch kleinanzeigen search: ${response.error.message}`); + } + + // Build the final URL for reference + const finalUrl = `${url}${pathTemplate}${Object.keys(queryParams).length > 0 ? '?' + new URLSearchParams(queryParams as Record).toString() : ''}`; + + return { + html: response.value.data, + url: finalUrl + }; +} + +export type TFetchAds = { + url?: string; + query?: string; + location?: string; + radius?: number; + minPrice?: number; + maxPrice?: number; + page?: number; +}; + +export type TFetchAdsResult = { + html: string; + url: string; +}; diff --git a/packages/_deprecated/kleinanzeigen-client/src/index.ts b/packages/_deprecated/kleinanzeigen-client/src/index.ts new file mode 100644 index 00000000..d1775404 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/src/index.ts @@ -0,0 +1,2 @@ +export * from './extract-ads'; +export * from './fetch-ads'; diff --git a/packages/_deprecated/kleinanzeigen-client/tsconfig.json b/packages/_deprecated/kleinanzeigen-client/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/_deprecated/kleinanzeigen-client/tsconfig.prod.json b/packages/_deprecated/kleinanzeigen-client/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/_deprecated/kleinanzeigen-client/vitest.config.mjs b/packages/_deprecated/kleinanzeigen-client/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/_deprecated/kleinanzeigen-client/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/config/package.json b/packages/config/package.json index 72c06f88..f1dbc43e 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -38,23 +38,23 @@ "update:latest": "pnpm update --latest" }, "dependencies": { - "@ianvs/prettier-plugin-sort-imports": "^4.4.1", - "@next/eslint-plugin-next": "^15.3.2", - "@typescript-eslint/eslint-plugin": "^8.32.1", - "@typescript-eslint/parser": "^8.32.1", + "@ianvs/prettier-plugin-sort-imports": "^4.4.2", + "@next/eslint-plugin-next": "^15.3.3", + "@typescript-eslint/eslint-plugin": "^8.33.0", + "@typescript-eslint/parser": "^8.33.0", "eslint-config-prettier": "^10.1.5", "eslint-plugin-only-warn": "^1.1.0", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-turbo": "^2.5.3", + "eslint-plugin-turbo": "^2.5.4", "prettier-plugin-css-order": "^2.1.2", - "prettier-plugin-packagejson": "^2.5.14", - "prettier-plugin-tailwindcss": "^0.6.11", - "typescript-eslint": "^8.32.1", + "prettier-plugin-packagejson": "^2.5.15", + "prettier-plugin-tailwindcss": "^0.6.12", + "typescript-eslint": "^8.33.0", "vite-tsconfig-paths": "^5.1.4" }, "devDependencies": { - "eslint": "^9.27.0", + "eslint": "^9.28.0", "prettier": "^3.5.3", "typescript": "^5.8.3", "vitest": "^3.1.4" diff --git a/packages/elevenlabs-client/package.json b/packages/elevenlabs-client/package.json index efc03b42..e349fc7b 100644 --- a/packages/elevenlabs-client/package.json +++ b/packages/elevenlabs-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/eprel-client/package.json b/packages/eprel-client/package.json index 105a9241..48c02352 100644 --- a/packages/eprel-client/package.json +++ b/packages/eprel-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/feature-ecs/.github/banner.svg b/packages/feature-ecs/.github/banner.svg new file mode 100644 index 00000000..a41d88ec --- /dev/null +++ b/packages/feature-ecs/.github/banner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/feature-ecs/README.md b/packages/feature-ecs/README.md new file mode 100644 index 00000000..e3804bd2 --- /dev/null +++ b/packages/feature-ecs/README.md @@ -0,0 +1,385 @@ +

+ feature-ecs banner +

+ +

+ + GitHub License + + + NPM bundle minzipped size + + + NPM total downloads + + + Join Discord + +

+ +`feature-ecs` is a flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript. + +- **🔮 Simple, declarative API**: Intuitive component patterns with full type safety +- **🍃 Lightweight & Tree Shakable**: Function-based and modular design +- **⚡ High Performance**: O(1) component checks using bitflags, cache-friendly sparse arrays +- **🔍 Powerful Querying**: Query entities with complex filters and get component data efficiently +- **📦 Zero Dependencies**: Standalone library ensuring ease of use in various environments +- **🔧 Flexible Storage**: Supports AoS, SoA, and marker component patterns +- **🧵 Change Tracking**: Built-in tracking for added, changed, and removed components + +### 📚 Examples + +- [Basic](https://github.com/builder-group/community/tree/develop/examples/feature-ecs/bsic) + +### 🌟 Motivation + +Create a modern, typesafe ECS library that embraces TypeScript's type system while maintaining the performance characteristics that make ECS powerful. While there are some promising ECS libraries like bitECS with great performance, they often lack TypeScript support and advanced query features like `Added()`, `Removed()`, `Changed()` filters for reactive systems. `feature-ecs` aims to provide the best of both worlds: high performance with full TypeScript integration and powerful querying capabilities, following the KISS principle to keep the API simple yet comprehensive. + +### ⚖️ Alternatives + +- [bitECS](https://github.com/NateTheGreatt/bitECS) +- [ecsy](https://github.com/ecsyjs/ecsy) + +## 📖 Usage + +`feature-ecs` offers core ECS concepts without imposing strict rules onto your architecture: + +- **Entities** are numerical IDs representing game objects +- **Components** are data containers that can follow different storage patterns +- **Systems** are just functions that query and process entities +- **Queries** provide powerful filtering with change detection + +For optimal performance: + +- Use [Array of Structures (AoS) format](https://en.wikipedia.org/wiki/AoS_and_SoA) for related component properties +- Implement systems as pure functions operating on query results + +### Basic Setup + +```ts +import { And, createWorld, With } from 'feature-ecs'; + +// Define components - no registration needed! +const Position = { x: [], y: [] }; // AoS pattern +const Velocity = { dx: [], dy: [] }; // AoS pattern +const Health = []; // Single value array +const Player = {}; // Marker component + +// Create world +const world = createWorld(); +``` + +### Entity Management + +```ts +// Create entity +const entity = world.createEntity(); + +// Destroy entity (removes all components) +world.destroyEntity(entity); +``` + +### Component Operations + +```ts +// Add components +world.addComponent(entity, Position, { x: 100, y: 50 }); +world.addComponent(entity, Velocity, { dx: 2, dy: 1 }); +world.addComponent(entity, Health, 100); +world.addComponent(entity, Player, true); + +// Update components (AoS) +world.updateComponent(entity, Position, { x: 110 }); +world.updateComponent(entity, Health, 95); +world.updateComponent(entity, Player, false); // Also removes marker + +// Direct updates - mark as changed for reactive queries +Position.x[entity] = 110; +world.markComponentChanged(entity, Position); +Health[entity] = 95; +world.markComponentChanged(entity, Health); + +// Remove component +world.removeComponent(entity, Velocity); + +// Check component +if (world.hasComponent(entity, Player)) { + // Entity is a player +} +``` + +### Querying + +```ts +import { Added, And, Changed, Or, Removed, With, Without } from 'feature-ecs'; + +// Query entity IDs +const players = world.queryEntities(With(Player)); +const moving = world.queryEntities(And(With(Position), With(Velocity))); +const damaged = world.queryEntities(Changed(Health)); + +// Query with component data +for (const [eid, pos, health] of world.queryComponents([Entity, Position, Health] as const)) { + console.log(`Entity ${eid} at (${pos.x}, ${pos.y}) with ${health} health`); +} +``` + +### Game Loop + +```ts +function update(deltaTime: number) { + // Movement system + for (const [eid, pos, vel] of world.queryComponents([Entity, Position, Velocity] as const)) { + world.updateComponent(eid, Position, { + x: pos.x + vel.dx * deltaTime, + y: pos.y + vel.dy * deltaTime + }); + } + + // Clear change tracking + world.flush(); +} +``` + +## 📐 Architecture + +### Entity Index + +Efficient entity ID management using sparse-dense array pattern with optional versioning. Provides O(1) operations while maintaining cache-friendly iteration. + +#### Sparse-Dense Pattern + +``` +Sparse Array: [_, 0, _, 2, 1, _, _] ← Maps entity ID → dense index + 1 2 3 4 5 6 7 ← Entity IDs + +Dense Array: [2, 5, 4, 7, 3] ← Alive entities (cache-friendly) + [0, 1, 2, 3, 4] ← Indices + └─alive─┘ └dead┘ + +aliveCount: 3 ← First 3 elements are alive +``` + +**Core Data:** + +- **Sparse Array**: Maps base entity IDs to dense array positions +- **Dense Array**: Contiguous alive entities, with dead entities at end +- **Alive Count**: Boundary between alive/dead entities + +#### Entity ID Format + +``` +32-bit Entity ID = [Version Bits | Entity ID Bits] + +Example with 8 version bits: +┌─ Version (8 bits) ─┐┌─── Entity ID (24 bits) ───┐ +00000001 000000000000000000000001 +│ │ +└─ Version 1 └─ Base Entity ID 1 +``` + +#### Why This Design? + +**Problem: Stale References** + +```typescript +const entity = addEntity(); // Returns ID 5 +removeEntity(entity); // Removes ID 5 +const newEntity = addEntity(); // Might reuse ID 5! +// Bug: old reference to ID 5 now points to wrong entity +``` + +**Solution: Versioning** + +```typescript +const entity = addEntity(); // Returns 5v0 (ID 5, version 0) +removeEntity(entity); // Increments to 5v1 +const newEntity = addEntity(); // Reuses base ID 5 but as 5v1 +// Safe: old reference (5v0) won't match new entity (5v1) +``` + +**Swap-and-Pop for O(1) Removal** + +```typescript +// Remove entity at index 1: +dense = [1, 2, 3, 4, 5]; +// 1. Swap with last: [1, 5, 3, 4, 2] +// 2. Decrease alive count +// Result: [1, 5, 3, 4 | 2] - only alive section matters +``` + +**Performance:** O(1) all operations, ~8 bytes per entity, cache-friendly iteration. + +### Query System + +Entity filtering with two strategies: bitmask optimization for simple queries, individual evaluation for complex queries. + +#### Query Filters + +```typescript +// Component filters +With(Position); // Entity must have component +Without(Dead); // Entity must not have component + +// Change detection +Added(Position); // Component added this frame +Changed(Health); // Component modified this frame +Removed(Velocity); // Component removed this frame + +// Logical operators +And(With(Position), With(Velocity)); // All must match +Or(With(Player), With(Enemy)); // Any must match +``` + +#### Evaluation Strategies + +**Bitmask Strategy** - Fast bitwise operations: + +```typescript +// Components get bit positions +Position: bitflag=0b001, Velocity: bitflag=0b010, Health: bitflag=0b100 + +// Entity masks show what components each entity has +entity1: 0b011 // Has Position + Velocity +entity2: 0b101 // Has Position + Health + +// Query: And(With(Position), With(Velocity)) → withMask = 0b011 +// Check: (entityMask & 0b011) === 0b011 +entity1: (0b011 & 0b011) === 0b011 ✓ true +entity2: (0b101 & 0b011) === 0b011 ✗ false +``` + +**Individual Strategy** - Per-filter evaluation for complex queries: + +```typescript +// Complex queries like Or(With(Position), Changed(Health)) +// Fall back to: filters.some(filter => filter.evaluate(world, eid)) +``` + +#### Performance (10,000 entities) + +``` + individual + cached - __tests__/query.bench.ts > Query Performance > With(Position) + 1.04x faster than bitmask + cached + 7.50x faster than bitmask + no cache + 7.83x faster than individual + no cache + + bitmask + cached - __tests__/query.bench.ts > Query Performance > And(With(Position), With(Velocity)) + 1.01x faster than individual + cached + 13.58x faster than bitmask + no cache + 13.72x faster than individual + no cache +``` + +**Key Insight:** Caching matters most (7-14x faster than no cache). Bitmask vs individual evaluation shows minimal difference. + +### Component Registry + +Component management with direct array access, unlimited components via generations, and flexible storage patterns. + +#### Component Patterns + +```typescript +// Structure of Arrays (SoA) - cache-friendly for bulk operations +const Position = { x: [], y: [] }; +Position.x[eid] = 10; +Position.y[eid] = 20; + +// Array of Structures (AoS) - good for complete entity data +const Transform = []; +Transform[eid] = { x: 10, y: 20 }; + +// Single arrays and marker components +const Health = []; // Health[eid] = 100 +const Player = {}; // Just presence/absence +``` + +#### Generation System + +Unlimited components beyond 31-bit limit: + +**Why Generations?** Bitmasks need one bit per component for fast O(1) checks. JavaScript integers are 32-bit, giving us only 31 usable bits (0 - 30, bit 31 is sign). So we can only track 31 components per bitmask. + +```typescript +// Problem: Only 31 components fit in one integer bitmask +// Bits: 31 30 29 28 ... 3 2 1 0 +// Components: ❌ ✓ ✓ ✓ ... ✓ ✓ ✓ ✓ (31 components max) + +// Solution: Multiple generations, each with 31 components +// Generation 0: Components 0-30 (bitflags 1, 2, 4, ..., 2^30) +Position: { generationId: 0, bitflag: 0b001 } +Velocity: { generationId: 0, bitflag: 0b010 } + +// Generation 1: Components 31+ (bitflags restart) +Armor: { generationId: 1, bitflag: 0b001 } +Weapon: { generationId: 1, bitflag: 0b010 } + +// Entity masks stored per generation +_entityMasks[0][eid] = 0b011; // Has Position + Velocity +_entityMasks[1][eid] = 0b001; // Has Armor +``` + +#### Bitmask Operations + +```typescript +// Adding component: OR with bitflag +entityMask |= 0b010; // Add Velocity + +// Removing component: AND with inverted bitflag +entityMask &= ~0b010; // Remove Velocity + +// Checking component: AND with bitflag +const hasVelocity = (entityMask & 0b010) !== 0; +``` + +#### Change Tracking + +```typescript +// Separate masks track changes per frame +_addedMasks[0][eid] |= bitflag; // Component added +_changedMasks[0][eid] |= bitflag; // Component changed +_removedMasks[0][eid] |= bitflag; // Component removed + +// Clear at frame end +flush() { /* clear all change masks */ } +``` + +#### Why These Decisions? + +**Sparse Arrays:** Memory-efficient with large entity IDs - only allocated indices use memory. + +**Direct Array Access:** No function call overhead - `Health[eid] = 100` is fastest possible. + +**Flexible Patterns:** Physics systems benefit from SoA cache locality, UI systems need complete AoS objects. + +**Generations:** JavaScript 32-bit integers limit us to 31 components - generations provide unlimited components. + +**Performance:** O(1) operations, 4 bytes per entity per generation, direct memory access. + +## 📚 Good to Know + +### Sparse vs Dense Arrays + +JavaScript sparse arrays store only assigned indices, making them memory-efficient: + +```ts +const sparse = []; +sparse[1000] = 5; // [<1000 empty items>, 5] + +console.log(sparse.length); // 1001 +console.log(sparse[500]); // undefined (no memory used) +``` + +In contrast, dense arrays allocate memory for every element, even if unused: + +```ts +const dense = new Array(1001).fill(0); // Allocates 1001 × 4 bytes = ~4KB + +console.log(dense.length); // 1001 +console.log(dense[500]); // 0 +``` + +Use sparse arrays for large, mostly empty datasets. Use dense arrays when you need consistent iteration and performance. + +## 💡 Resources / References + +- [BitECS](https://github.com/NateTheGreatt/bitECS) - High-performance ECS library that inspired our implementation diff --git a/packages/feature-ecs/__tests__/component-variant.bench.ts b/packages/feature-ecs/__tests__/component-variant.bench.ts new file mode 100644 index 00000000..8c03634f --- /dev/null +++ b/packages/feature-ecs/__tests__/component-variant.bench.ts @@ -0,0 +1,161 @@ +import { bench, describe, expect } from 'vitest'; +import { createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('Component Variants Performance', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + + // Different component patterns + const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) + const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) + const Health: number[] = []; // Single value array + const Player: {} = {}; // Marker component + + describe('Add Component', () => { + bench('AoS - Position', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + + expect(world.hasComponent(eid, Position)).toBe(true); + }); + + bench('SoA - Transform', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Transform); + Transform[eid] = { x: random.next() * 1000, y: random.next() * 1000 }; + + expect(world.hasComponent(eid, Transform)).toBe(true); + }); + + bench('Single Array - Health', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + + expect(world.hasComponent(eid, Health)).toBe(true); + }); + + bench('Tag - Player', () => { + const world = createWorld(); + const eid = world.createEntity(); + + world.addComponent(eid, Player); + + expect(world.hasComponent(eid, Player)).toBe(true); + }); + }); + + describe('Remove Component', () => { + bench('AoS - Position', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Position); + Position.x[eid] = 100; + Position.y[eid] = 200; + + const removed = world.removeComponent(eid, Position); + expect(removed).toBe(true); + }); + + bench('SoA - Transform', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Transform); + Transform[eid] = { x: 100, y: 200 }; + + const removed = world.removeComponent(eid, Transform); + expect(removed).toBe(true); + }); + + bench('Single Array - Health', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Health); + Health[eid] = 100; + + const removed = world.removeComponent(eid, Health); + expect(removed).toBe(true); + }); + + bench('Tag - Player', () => { + const world = createWorld(); + const eid = world.createEntity(); + world.addComponent(eid, Player); + + const removed = world.removeComponent(eid, Player); + expect(removed).toBe(true); + }); + }); + + describe('Query Component', () => { + const worldAoS = createWorld(); + const worldSoA = createWorld(); + const worldSingle = createWorld(); + const worldTag = createWorld(); + + // AoS setup + for (let i = 0; i < 500; i++) { + const eid = worldAoS.createEntity(); + if (random.nextBool(0.7)) { + worldAoS.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + } + + // SoA setup + for (let i = 0; i < 500; i++) { + const eid = worldSoA.createEntity(); + if (random.nextBool(0.7)) { + worldSoA.addComponent(eid, Transform); + Transform[eid] = { x: random.next() * 1000, y: random.next() * 1000 }; + } + } + + // Single array setup + for (let i = 0; i < 500; i++) { + const eid = worldSingle.createEntity(); + if (random.nextBool(0.7)) { + worldSingle.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + // Tag setup + for (let i = 0; i < 500; i++) { + const eid = worldTag.createEntity(); + if (random.nextBool(0.7)) { + worldTag.addComponent(eid, Player); + } + } + + bench('AoS - Position', () => { + const entities = worldAoS.queryEntities(With(Position)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('SoA - Transform', () => { + const entities = worldSoA.queryEntities(With(Transform)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('Single Array - Health', () => { + const entities = worldSingle.queryEntities(With(Health)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('Tag - Player', () => { + const entities = worldTag.queryEntities(With(Player)); + expect(entities.length).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/ecs-comparison.bench.ts b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts new file mode 100644 index 00000000..de5e2a12 --- /dev/null +++ b/packages/feature-ecs/__tests__/ecs-comparison.bench.ts @@ -0,0 +1,172 @@ +import { + addComponent as bitECSAddComponent, + addEntity as bitECSAddEntity, + query as bitECSQuery, + createWorld as createBitEcsWorld +} from 'bitecs'; +import { bench, describe, expect } from 'vitest'; +import { And, createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('ECS Performance Comparison', () => { + const seed = Math.random() * 1000000; + + // FeatureEcs setup + const featureEcsRandom = createSeededRandom(seed); + const featureEcsWorld = createWorld(); + const FeatureEcsPosition = { x: [] as number[], y: [] as number[] }; + const FeatureEcsVelocity = { x: [] as number[], y: [] as number[] }; + const FeatureEcsHealth: number[] = []; + + // BitEcs setup + const bitEcsRandom = createSeededRandom(seed); + const bitECSWorld = createBitEcsWorld(); + const BitEcsPosition = { x: [] as number[], y: [] as number[] }; + const BitEcsVelocity = { x: [] as number[], y: [] as number[] }; + const BitEcsHealth = [] as number[]; + + describe('Entity Creation', () => { + bench('FeatureEcs - Create entity', () => { + const eid = featureEcsWorld.createEntity(); + expect(eid).toBeGreaterThanOrEqual(0); + }); + + bench('BitEcs - Create entity', () => { + const eid = bitECSAddEntity(bitECSWorld); + expect(eid).toBeGreaterThanOrEqual(0); + }); + }); + + describe('Component Addition', () => { + bench('FeatureEcs - Add Position component', () => { + const eid = featureEcsWorld.createEntity(); + featureEcsWorld.addComponent(eid, FeatureEcsPosition); + FeatureEcsPosition.x[eid] = 100; + FeatureEcsPosition.y[eid] = 200; + }); + + bench('BitEcs - Add Position component', () => { + const eid = bitECSAddEntity(bitECSWorld); + bitECSAddComponent(bitECSWorld, eid, BitEcsPosition); + BitEcsPosition.x[eid] = 100; + BitEcsPosition.y[eid] = 200; + }); + }); + + describe('Component Queries', () => { + for (let i = 0; i < 1000; i++) { + // FeatureEcs entities + const featureEcsEid = featureEcsWorld.createEntity(); + if (featureEcsRandom.nextBool(0.7)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsPosition); + FeatureEcsPosition.x[featureEcsEid] = i; + FeatureEcsPosition.y[featureEcsEid] = i * 2; + } + if (featureEcsRandom.nextBool(0.5)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsVelocity); + FeatureEcsVelocity.x[featureEcsEid] = 1.5; + FeatureEcsVelocity.y[featureEcsEid] = 2.0; + } + if (featureEcsRandom.nextBool(0.3)) { + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsHealth); + FeatureEcsHealth[featureEcsEid] = 100; + } + + // BitEcs entities + const bitEcsEid = bitECSAddEntity(bitECSWorld); + if (bitEcsRandom.nextBool(0.7)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsPosition); + BitEcsPosition.x[bitEcsEid] = i; + BitEcsPosition.y[bitEcsEid] = i * 2; + } + if (bitEcsRandom.nextBool(0.5)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsVelocity); + BitEcsVelocity.x[bitEcsEid] = 1.5; + BitEcsVelocity.y[bitEcsEid] = 2.0; + } + if (bitEcsRandom.nextBool(0.3)) { + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsHealth); + BitEcsHealth[bitEcsEid] = 100; + } + } + + bench('FeatureEcs - Query Position components', () => { + const entities = featureEcsWorld.queryEntities(With(FeatureEcsPosition)); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('BitEcs - Query Position components', () => { + const entities = Array.from(bitECSQuery(bitECSWorld, [BitEcsPosition])); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('FeatureEcs - Query Position + Velocity', () => { + const entities = featureEcsWorld.queryEntities( + And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) + ); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('BitEcs - Query Position + Velocity', () => { + const entities = bitECSQuery(bitECSWorld, [BitEcsPosition, BitEcsVelocity]); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + }); + + describe('System Iteration Performance', () => { + for (let i = 0; i < 5000; i++) { + const posX = featureEcsRandom.next() * 1000; + const posY = featureEcsRandom.next() * 1000; + const velX = (featureEcsRandom.next() - 0.5) * 10; + const velY = (featureEcsRandom.next() - 0.5) * 10; + + // FeatureEcs + const featureEcsEid = featureEcsWorld.createEntity(); + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsPosition); + featureEcsWorld.addComponent(featureEcsEid, FeatureEcsVelocity); + FeatureEcsPosition.x[featureEcsEid] = posX; + FeatureEcsPosition.y[featureEcsEid] = posY; + FeatureEcsVelocity.x[featureEcsEid] = velX; + FeatureEcsVelocity.y[featureEcsEid] = velY; + + // BitEcs + const bitEcsEid = bitECSAddEntity(bitECSWorld); + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsPosition); + bitECSAddComponent(bitECSWorld, bitEcsEid, BitEcsVelocity); + BitEcsPosition.x[bitEcsEid] = posX; + BitEcsPosition.y[bitEcsEid] = posY; + BitEcsVelocity.x[bitEcsEid] = velX; + BitEcsVelocity.y[bitEcsEid] = velY; + } + + bench('FeatureEcs - Movement system iteration', () => { + let updateCount = 0; + + for (const eid of featureEcsWorld.queryEntities( + And(With(FeatureEcsPosition), With(FeatureEcsVelocity)) + )) { + const velX = FeatureEcsVelocity.x[eid] ?? 0; + const velY = FeatureEcsVelocity.y[eid] ?? 0; + FeatureEcsPosition.x[eid] = (FeatureEcsPosition.x[eid] ?? 0) + velX * 0.016; // 60fps delta + FeatureEcsPosition.y[eid] = (FeatureEcsPosition.y[eid] ?? 0) + velY * 0.016; + updateCount++; + } + + expect(updateCount).toBeGreaterThan(0); + }); + + bench('BitEcs - Movement system iteration', () => { + let updateCount = 0; + + for (const eid of bitECSQuery(bitECSWorld, [BitEcsPosition, BitEcsVelocity])) { + const velX = BitEcsVelocity.x[eid] ?? 0; + const velY = BitEcsVelocity.y[eid] ?? 0; + BitEcsPosition.x[eid] = (BitEcsPosition.x[eid] ?? 0) + velX * 0.016; // 60fps delta + BitEcsPosition.y[eid] = (BitEcsPosition.y[eid] ?? 0) + velY * 0.016; + updateCount++; + } + + expect(updateCount).toBeGreaterThan(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/playground.test.ts b/packages/feature-ecs/__tests__/playground.test.ts new file mode 100644 index 00000000..8aa4f120 --- /dev/null +++ b/packages/feature-ecs/__tests__/playground.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; +import { createWorld, With } from '../src'; +import { createSeededRandom } from './utils'; + +describe('playground', () => { + it('should pass', () => { + expect(true).toBe(true); + }); + + it.skip('should work', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + const world = createWorld(); + + // Component definitions + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health: number[] = []; + + // Create 2000 test entities with deterministic distribution + for (let i = 0; i < 2000; i++) { + const eid = world.createEntity(); + + if (random.nextBool(0.8)) { + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + + if (random.nextBool(0.6)) { + world.addComponent(eid, Velocity); + Velocity.x[eid] = (random.next() - 0.5) * 10; + Velocity.y[eid] = (random.next() - 0.5) * 10; + } + + if (random.nextBool(0.7)) { + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + const entities = world.queryEntities(With(Position), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/feature-ecs/__tests__/query.bench.ts b/packages/feature-ecs/__tests__/query.bench.ts new file mode 100644 index 00000000..7589e47c --- /dev/null +++ b/packages/feature-ecs/__tests__/query.bench.ts @@ -0,0 +1,138 @@ +import { bench, describe, expect } from 'vitest'; +import { And, createWorld, With, Without } from '../src'; +import { createSeededRandom } from './utils'; + +describe('Query Performance', () => { + const seed = Math.random() * 1000000; + const random = createSeededRandom(seed); + const world = createWorld(); + + // Component definitions + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health: number[] = []; + + // Create 2000 test entities with deterministic distribution + for (let i = 0; i < 2000; i++) { + const eid = world.createEntity(); + + if (random.nextBool(0.8)) { + world.addComponent(eid, Position); + Position.x[eid] = random.next() * 1000; + Position.y[eid] = random.next() * 1000; + } + + if (random.nextBool(0.6)) { + world.addComponent(eid, Velocity); + Velocity.x[eid] = (random.next() - 0.5) * 10; + Velocity.y[eid] = (random.next() - 0.5) * 10; + } + + if (random.nextBool(0.7)) { + world.addComponent(eid, Health); + Health[eid] = Math.floor(random.next() * 100) + 1; + } + } + + describe('With(Position)', () => { + bench('bitmask + cached', () => { + const entities = world.queryEntities(With(Position), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.queryEntities(With(Position), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + cached', () => { + const entities = world.queryEntities(With(Position), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + no cache', () => { + const entities = world.queryEntities(With(Position), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + }); + + describe('And(With(Position), With(Velocity))', () => { + bench('bitmask + cached', () => { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + cached', () => { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThan(0); + }); + + bench('individual + no cache', () => { + const entities = world.queryEntities(And(With(Position), With(Velocity)), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThan(0); + }); + }); + + describe('And(With(Position), Without(Health))', () => { + bench('bitmask + cached', () => { + const entities = world.queryEntities(And(With(Position), Without(Health)), { + evaluationStrategy: 'bitmask', + cache: true + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('bitmask + no cache', () => { + const entities = world.queryEntities(And(With(Position), Without(Health)), { + evaluationStrategy: 'bitmask', + cache: false + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('individual + cached', () => { + const entities = world.queryEntities(And(With(Position), Without(Health)), { + evaluationStrategy: 'individual', + cache: true + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + + bench('individual + no cache', () => { + const entities = world.queryEntities(And(With(Position), Without(Health)), { + evaluationStrategy: 'individual', + cache: false + }); + expect(entities.length).toBeGreaterThanOrEqual(0); + }); + }); +}); diff --git a/packages/feature-ecs/__tests__/utils/create-seeded-random.ts b/packages/feature-ecs/__tests__/utils/create-seeded-random.ts new file mode 100644 index 00000000..c157b970 --- /dev/null +++ b/packages/feature-ecs/__tests__/utils/create-seeded-random.ts @@ -0,0 +1,15 @@ +export function createSeededRandom(seed: number) { + const random = function (): number { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; + + return { + next: random, + nextBool: (probability = 0.5) => random() < probability, + nextInt: (max: number) => Math.floor(random() * max) + }; +} diff --git a/packages/feature-ecs/__tests__/utils/index.ts b/packages/feature-ecs/__tests__/utils/index.ts new file mode 100644 index 00000000..b42a3c97 --- /dev/null +++ b/packages/feature-ecs/__tests__/utils/index.ts @@ -0,0 +1 @@ +export * from './create-seeded-random'; diff --git a/packages/feature-ecs/eslint.config.js b/packages/feature-ecs/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/feature-ecs/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/feature-ecs/package.json b/packages/feature-ecs/package.json new file mode 100644 index 00000000..e57dd45f --- /dev/null +++ b/packages/feature-ecs/package.json @@ -0,0 +1,51 @@ +{ + "name": "feature-ecs", + "version": "0.0.2", + "private": false, + "description": "A flexible, typesafe, and performance-focused Entity Component System (ECS) library for TypeScript.", + "keywords": [], + "homepage": "https://builder.group/?source=github", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "@blgc/utils": "^0.0.52" + }, + "devDependencies": { + "@blgc/config": "workspace:*", + "@types/node": "^22.15.29", + "bitecs": "github:NateTheGreatt/bitECS#rc-0-4-0", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/feature-ecs/rollup.config.js b/packages/feature-ecs/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/feature-ecs/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/feature-ecs/src/component/create-component-registry.test.ts b/packages/feature-ecs/src/component/create-component-registry.test.ts new file mode 100644 index 00000000..3af10a01 --- /dev/null +++ b/packages/feature-ecs/src/component/create-component-registry.test.ts @@ -0,0 +1,940 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createEntityIndex, TEntityIndex } from '../entity/create-entity-index'; +import { createComponentRegistry, TComponentRegistry } from './create-component-registry'; + +describe('createComponentRegistry', () => { + let registry: TComponentRegistry; + let entityIndex: TEntityIndex; + + beforeEach(() => { + registry = createComponentRegistry(); + entityIndex = createEntityIndex(); + }); + + describe('registerComponent', () => { + it('should register components and return metadata', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + const posData = registry.registerComponent(Position); + const transformData = registry.registerComponent(Transform); + const healthData = registry.registerComponent(Health); + const playerData = registry.registerComponent(Player); + + expect(posData.id).toBe(0); + expect(posData.generationId).toBe(0); + expect(posData.bitflag).toBe(1); + expect(posData.ref).toBe(Position); + + expect(transformData.id).toBe(1); + expect(transformData.generationId).toBe(0); + expect(transformData.bitflag).toBe(2); + expect(transformData.ref).toBe(Transform); + + expect(healthData.id).toBe(2); + expect(healthData.generationId).toBe(0); + expect(healthData.bitflag).toBe(4); + expect(healthData.ref).toBe(Health); + + expect(playerData.id).toBe(3); + expect(playerData.generationId).toBe(0); + expect(playerData.bitflag).toBe(8); + expect(playerData.ref).toBe(Player); + + // Re-registering should return same data + const posData2 = registry.registerComponent(Position); + expect(posData2).toBe(posData); + }); + + it('should support unlimited components', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + const data = registry.registerComponent(component); + + if (i < 31) { + // First generation (0-30) + expect(data.generationId).toBe(0); + expect(data.bitflag).toBe(2 ** i); + } else { + // Second generation (31-34) + expect(data.generationId).toBe(1); + expect(data.bitflag).toBe(2 ** (i - 31)); + } + } + }); + }); + + describe('hasComponent', () => { + it('should return true for entities with components', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + expect(registry.hasComponent(eid1, Position)).toBe(true); + expect(registry.hasComponent(eid1, Health)).toBe(true); + expect(registry.hasComponent(eid2, Position)).toBe(true); + expect(registry.hasComponent(eid2, Health)).toBe(false); + }); + + it('should return false for unregistered components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + expect(registry.hasComponent(eid, Position)).toBe(false); + }); + }); + + describe('addComponent', () => { + it('should support object with array properties pattern (AoS)', () => { + const Position: TPosition = { x: [], y: [] }; + const Velocity: TVelocity = { dx: [], dy: [] }; + + registry.registerComponent(Position); + registry.registerComponent(Velocity); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Velocity); + registry.addComponent(eid2, Position); + + // Set data on separate arrays for each property + Position.x[eid1] = 10; + Position.y[eid1] = 20; + Velocity.dx[eid1] = 1; + Velocity.dy[eid1] = 2; + Position.x[eid2] = 30; + Position.y[eid2] = 40; + + // Check components + expect(registry.hasComponent(eid1, Position)).toBe(true); + expect(registry.hasComponent(eid1, Velocity)).toBe(true); + expect(registry.hasComponent(eid2, Position)).toBe(true); + expect(registry.hasComponent(eid2, Velocity)).toBe(false); + + // Verify data + expect(Position.x[eid1]).toBe(10); + expect(Position.y[eid1]).toBe(20); + expect(Velocity.dx[eid1]).toBe(1); + expect(Velocity.dy[eid1]).toBe(2); + expect(Position.x[eid2]).toBe(30); + expect(Position.y[eid2]).toBe(40); + }); + + it('should support array of objects pattern (SoA)', () => { + const Transform: TTransform = []; + const RenderInfo: TRenderInfo = []; + + registry.registerComponent(Transform); + registry.registerComponent(RenderInfo); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Transform); + registry.addComponent(eid1, RenderInfo); + registry.addComponent(eid2, Transform); + + // Set data as complete objects + Transform[eid1] = { x: 5, y: 15, rotation: 45 }; + RenderInfo[eid1] = { sprite: 'player.png', layer: 1, visible: true }; + Transform[eid2] = { x: 100, y: 200, rotation: 0 }; + + // Check components + expect(registry.hasComponent(eid1, Transform)).toBe(true); + expect(registry.hasComponent(eid1, RenderInfo)).toBe(true); + expect(registry.hasComponent(eid2, Transform)).toBe(true); + expect(registry.hasComponent(eid2, RenderInfo)).toBe(false); + + // Verify data + expect(Transform[eid1]).toEqual({ x: 5, y: 15, rotation: 45 }); + expect(RenderInfo[eid1]).toEqual({ sprite: 'player.png', layer: 1, visible: true }); + expect(Transform[eid2]).toEqual({ x: 100, y: 200, rotation: 0 }); + }); + + it('should support single value array pattern', () => { + const Health: THealth = []; + const Mana: TMana = []; + const Level: TLevel = []; + + registry.registerComponent(Health); + registry.registerComponent(Mana); + registry.registerComponent(Level); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + // Add components + registry.addComponent(eid1, Health); + registry.addComponent(eid1, Mana); + registry.addComponent(eid1, Level); + registry.addComponent(eid2, Health); + + // Set single values + Health[eid1] = 100; + Mana[eid1] = 50; + Level[eid1] = 5; + Health[eid2] = 80; + + // Check components + expect(registry.hasComponent(eid1, Health)).toBe(true); + expect(registry.hasComponent(eid1, Mana)).toBe(true); + expect(registry.hasComponent(eid1, Level)).toBe(true); + expect(registry.hasComponent(eid2, Health)).toBe(true); + expect(registry.hasComponent(eid2, Mana)).toBe(false); + + // Verify data + expect(Health[eid1]).toBe(100); + expect(Mana[eid1]).toBe(50); + expect(Level[eid1]).toBe(5); + expect(Health[eid2]).toBe(80); + }); + + it('should support marker component pattern', () => { + const Player: TPlayer = {}; + const Enemy: TEnemy = {}; + const Frozen: TFrozen = {}; + + registry.registerComponent(Player); + registry.registerComponent(Enemy); + registry.registerComponent(Frozen); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + const eid3 = entityIndex.addEntity(); + + // Add marker components (no data, just flags) + registry.addComponent(eid1, Player); + registry.addComponent(eid1, Frozen); + registry.addComponent(eid2, Enemy); + registry.addComponent(eid3, Player); + + // Check components + expect(registry.hasComponent(eid1, Player)).toBe(true); + expect(registry.hasComponent(eid1, Enemy)).toBe(false); + expect(registry.hasComponent(eid1, Frozen)).toBe(true); + expect(registry.hasComponent(eid2, Player)).toBe(false); + expect(registry.hasComponent(eid2, Enemy)).toBe(true); + expect(registry.hasComponent(eid3, Player)).toBe(true); + expect(registry.hasComponent(eid3, Frozen)).toBe(false); + }); + + it('should auto-register components when adding', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + // Component should be auto-registered when adding + registry.addComponent(eid, Position); + expect(registry.hasComponent(eid, Position)).toBe(true); + + // Should be able to set data + Position.x[eid] = 10; + Position.y[eid] = 20; + expect(Position.x[eid]).toBe(10); + expect(Position.y[eid]).toBe(20); + }); + + it('should handle duplicate component addition as idempotent', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + // Add component multiple times + registry.addComponent(eid, Position); + registry.addComponent(eid, Position); + registry.addComponent(eid, Position); + + // Should still only have it once + expect(registry.hasComponent(eid, Position)).toBe(true); + + // Set data + Position.x[eid] = 10; + Position.y[eid] = 20; + + // Remove once should remove it completely + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + // Removing again should return false + expect(registry.removeComponent(eid, Position)).toBe(false); + }); + + it('should work across multiple generations', () => { + const eid = entityIndex.addEntity(); + + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + registry.registerComponent(component); + } + + // Add components from both generations + registry.addComponent(eid, components[0]!); // Gen 0, bitflag 1 + registry.addComponent(eid, components[30]!); // Gen 0, bitflag 2^30 + registry.addComponent(eid, components[31]!); // Gen 1, bitflag 1 + registry.addComponent(eid, components[34]!); // Gen 1, bitflag 8 + + // Verify components exist + expect(registry.hasComponent(eid, components[0]!)).toBe(true); + expect(registry.hasComponent(eid, components[30]!)).toBe(true); + expect(registry.hasComponent(eid, components[31]!)).toBe(true); + expect(registry.hasComponent(eid, components[34]!)).toBe(true); + }); + }); + + describe('updateComponent', () => { + it('should update array components and mark as changed by default', () => { + const Health: THealth = []; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Health); + registry.updateComponent(eid, Health, 100); + + expect(Health[eid]).toBe(100); + expect(registry.wasChanged(eid, Health)).toBe(true); + }); + + it('should update array components without marking as changed when explicitly false', () => { + const Health: THealth = []; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Health); + registry.updateComponent(eid, Health, 75, false); + + expect(Health[eid]).toBe(75); + expect(registry.wasChanged(eid, Health)).toBe(false); + }); + + it('should update object with arrays (AoS) and mark as changed by default', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + registry.updateComponent(eid, Position, { x: 10, y: 20 }); + + expect(Position.x[eid]).toBe(10); + expect(Position.y[eid]).toBe(20); + expect(registry.wasChanged(eid, Position)).toBe(true); + }); + + it('should update object with arrays without marking as changed when explicitly false', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + registry.updateComponent(eid, Position, { x: 15, y: 25 }, false); + + expect(Position.x[eid]).toBe(15); + expect(Position.y[eid]).toBe(25); + expect(registry.wasChanged(eid, Position)).toBe(false); + }); + + it('should add marker component when value is true', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.updateComponent(eid, Player, true); + + expect(registry.hasComponent(eid, Player)).toBe(true); + expect(registry.wasAdded(eid, Player)).toBe(true); + }); + + it('should remove marker component when value is false', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Player); + registry.updateComponent(eid, Player, false); + + expect(registry.hasComponent(eid, Player)).toBe(false); + expect(registry.wasRemoved(eid, Player)).toBe(true); + }); + + it('should not add marker component if already present', () => { + const Player: TPlayer = {}; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Player); + const wasAddedBefore = registry.wasAdded(eid, Player); + + registry.flush(); // Clear tracking + registry.updateComponent(eid, Player, true); + + expect(registry.hasComponent(eid, Player)).toBe(true); + expect(registry.wasAdded(eid, Player)).toBe(false); // Should not be marked as added again + }); + + it('should handle partial object updates', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + Position.x[eid] = 100; + Position.y[eid] = 200; + + registry.updateComponent(eid, Position, { x: 50 }, false); + + expect(Position.x[eid]).toBe(50); + expect(Position.y[eid]).toBe(200); // Should remain unchanged + }); + + it('should handle mixed array types in objects', () => { + const Mixed = { numbers: [] as number[], strings: [] as string[] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Mixed); + registry.updateComponent(eid, Mixed, { numbers: 42, strings: 'test' }, false); + + expect(Mixed.numbers[eid]).toBe(42); + expect(Mixed.strings[eid]).toBe('test'); + }); + }); + + describe('removeComponent', () => { + it('should remove components and clear data', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + + const eid = entityIndex.addEntity(); + + // Add components and set data + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove Position component + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + // Other components should still exist + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(Transform[eid]).toEqual({ x: 5, y: 15, rotation: 45 }); + expect(Health[eid]).toBe(100); + }); + + it('should return false for non-existent components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + expect(registry.removeComponent(eid, Position)).toBe(false); + }); + + it('should return false for already removed components', () => { + const Position: TPosition = { x: [], y: [] }; + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, Position); + expect(registry.removeComponent(eid, Position)).toBe(true); + expect(registry.removeComponent(eid, Position)).toBe(false); + }); + + it('should work across multiple generations', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + // Add some gen 0 components first + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + // These will be in generation 1 + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); + + const eid = entityIndex.addEntity(); + + // Add components from both generations + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + registry.addComponent(eid, Player); + + // Set data + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove components from different generations + registry.removeComponent(eid, Position); + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + + registry.removeComponent(eid, gen0Components[0]!); + expect(registry.hasComponent(eid, gen0Components[0]!)).toBe(false); + + // Other components should still exist + expect(registry.hasComponent(eid, Transform)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(true); + expect(registry.hasComponent(eid, Player)).toBe(true); + }); + }); + + describe('removeAllComponents', () => { + it('should remove all components from entity', () => { + const Position: TPosition = { x: [], y: [] }; + const Transform: TTransform = []; + const Health: THealth = []; + const Player: TPlayer = {}; + + registry.registerComponent(Position); + registry.registerComponent(Transform); + registry.registerComponent(Health); + registry.registerComponent(Player); + + const eid = entityIndex.addEntity(); + + // Add components and set data + registry.addComponent(eid, Position); + registry.addComponent(eid, Transform); + registry.addComponent(eid, Health); + registry.addComponent(eid, Player); + + Position.x[eid] = 10; + Position.y[eid] = 20; + Transform[eid] = { x: 5, y: 15, rotation: 45 }; + Health[eid] = 100; + + // Remove all components + registry.removeAllComponents(eid); + + // All components should be removed + expect(registry.hasComponent(eid, Position)).toBe(false); + expect(registry.hasComponent(eid, Transform)).toBe(false); + expect(registry.hasComponent(eid, Health)).toBe(false); + expect(registry.hasComponent(eid, Player)).toBe(false); + + // All data should be cleared + expect(Position.x[eid]).toBeUndefined(); + expect(Position.y[eid]).toBeUndefined(); + expect(Transform[eid]).toBeUndefined(); + expect(Health[eid]).toBeUndefined(); + }); + }); + + describe('reset', () => { + it('should reset to initial state and clear all data', () => { + const Position: TPosition = { x: [], y: [] }; + + // Create multiple generations + const gen0Components = []; + for (let i = 0; i < 31; i++) { + const component = {}; + gen0Components.push(component); + registry.registerComponent(component); + } + + registry.registerComponent(Position); + + const eid = entityIndex.addEntity(); + + registry.addComponent(eid, gen0Components[0]!); + registry.addComponent(eid, Position); + Position.x[eid] = 10; + + // Verify data exists + expect(Position.x[eid]).toBe(10); + expect(registry.hasComponent(eid, Position)).toBe(true); + + // Reset registry + registry.reset(); + + // All arrays should be cleared + expect(Position.x.length).toBe(0); + + // Registry should be empty + expect(registry.hasComponent(eid, Position)).toBe(false); + }); + }); + + describe('change tracking', () => { + it('should track component additions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Initially, component should not be marked as added + expect(registry.wasAdded(eid, Position)).toBe(false); + + // Add component + registry.addComponent(eid, Position); + + // Now it should be marked as added + expect(registry.wasAdded(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(true); + }); + + it('should track component removals', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Add component first + registry.addComponent(eid, Position); + expect(registry.wasRemoved(eid, Position)).toBe(false); + + // Remove component + registry.removeComponent(eid, Position); + + // Now it should be marked as removed + expect(registry.wasRemoved(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Position)).toBe(false); + }); + + it('should track component changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Add component first + registry.addComponent(eid, Position); + expect(registry.wasChanged(eid, Position)).toBe(false); + + // Mark as changed + const result = registry.markChanged(eid, Position); + + // Should be marked as changed + expect(result).toBe(true); + expect(registry.wasChanged(eid, Position)).toBe(true); + }); + + it('should not mark non-existent components as changed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Try to mark non-existent component as changed + const result = registry.markChanged(eid, Position); + + // Should fail + expect(result).toBe(false); + expect(registry.wasChanged(eid, Position)).toBe(false); + }); + + it('should clear frame changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid = 1; + + // Add components and mark changes + registry.addComponent(eid, Position); + registry.addComponent(eid, Health); + registry.markChanged(eid, Position); + registry.removeComponent(eid, Health); + + // Verify changes are tracked + expect(registry.wasAdded(eid, Position)).toBe(true); + expect(registry.wasChanged(eid, Position)).toBe(true); + expect(registry.wasRemoved(eid, Health)).toBe(true); + + // Clear frame changes + registry.flush(); + + // Changes should be cleared + expect(registry.wasAdded(eid, Position)).toBe(false); + expect(registry.wasChanged(eid, Position)).toBe(false); + expect(registry.wasRemoved(eid, Health)).toBe(false); + + // But component state should remain + expect(registry.hasComponent(eid, Position)).toBe(true); + expect(registry.hasComponent(eid, Health)).toBe(false); + }); + + it('should handle multiple entities and components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Entity 1: Add Position, mark changed + registry.addComponent(eid1, Position); + registry.markChanged(eid1, Position); + + // Entity 2: Add Health, remove it + registry.addComponent(eid2, Health); + registry.removeComponent(eid2, Health); + + // Verify tracking for entity 1 + expect(registry.wasAdded(eid1, Position)).toBe(true); + expect(registry.wasChanged(eid1, Position)).toBe(true); + expect(registry.wasRemoved(eid1, Health)).toBe(false); + + // Verify tracking for entity 2 + expect(registry.wasAdded(eid2, Health)).toBe(true); + expect(registry.wasRemoved(eid2, Health)).toBe(true); + expect(registry.wasChanged(eid2, Position)).toBe(false); + }); + + it('should work with multi-generation components', () => { + // Create 32 components to trigger generation overflow + const components = Array.from({ length: 32 }, (_, i) => ({ [`prop${i}`]: [] as number[] })); + const eid = 1; + + // Register all components (this will create multiple generations) + components.forEach((comp) => registry.registerComponent(comp)); + + // Add components from different generations + registry.addComponent(eid, components[0]); // Generation 0 + registry.addComponent(eid, components[31]); // Generation 1 + + // Verify tracking works across generations + expect(registry.wasAdded(eid, components[0])).toBe(true); + expect(registry.wasAdded(eid, components[31])).toBe(true); + + // Mark changes and verify + registry.markChanged(eid, components[0]); + registry.markChanged(eid, components[31]); + + expect(registry.wasChanged(eid, components[0])).toBe(true); + expect(registry.wasChanged(eid, components[31])).toBe(true); + }); + }); + + describe('callback system', () => { + it('should call onAdd callbacks when components are added', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Setup callbacks + const positionAddedEntities: number[] = []; + const healthAddedEntities: number[] = []; + + registry.onComponentAdd(Position, (eid) => { + positionAddedEntities.push(eid); + }); + + registry.onComponentAdd(Health, (eid) => { + healthAddedEntities.push(eid); + }); + + // Add components + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Verify callbacks were called + expect(positionAddedEntities).toEqual([eid1, eid2]); + expect(healthAddedEntities).toEqual([eid1]); + }); + + it('should call onChange callbacks when components are marked as changed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Add components first + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Setup callbacks + const positionChangedEntities: number[] = []; + const healthChangedEntities: number[] = []; + + registry.onComponentChange(Position, (eid) => { + positionChangedEntities.push(eid); + }); + + registry.onComponentChange(Health, (eid) => { + healthChangedEntities.push(eid); + }); + + // Mark components as changed + registry.markChanged(eid1, Position); + registry.markChanged(eid1, Health); + registry.markChanged(eid2, Position); + + // Verify callbacks were called + expect(positionChangedEntities).toEqual([eid1, eid2]); + expect(healthChangedEntities).toEqual([eid1]); + }); + + it('should call onRemove callbacks when components are removed', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const eid1 = 1; + const eid2 = 2; + + // Add components first + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + // Setup callbacks + const positionRemovedEntities: number[] = []; + const healthRemovedEntities: number[] = []; + + registry.onComponentRemove(Position, (eid) => { + positionRemovedEntities.push(eid); + }); + + registry.onComponentRemove(Health, (eid) => { + healthRemovedEntities.push(eid); + }); + + // Remove components + registry.removeComponent(eid1, Position); + registry.removeComponent(eid1, Health); + registry.removeComponent(eid2, Position); + + // Verify callbacks were called + expect(positionRemovedEntities).toEqual([eid1, eid2]); + expect(healthRemovedEntities).toEqual([eid1]); + }); + + it('should not call callbacks for non-existent operations', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Setup callbacks + let addCallCount = 0; + let changeCallCount = 0; + let removeCallCount = 0; + + registry.onComponentAdd(Position, () => addCallCount++); + registry.onComponentChange(Position, () => changeCallCount++); + registry.onComponentRemove(Position, () => removeCallCount++); + + // Try to mark non-existent component as changed + registry.markChanged(eid, Position); + expect(changeCallCount).toBe(0); + + // Try to remove non-existent component + registry.removeComponent(eid, Position); + expect(removeCallCount).toBe(0); + + // Add component (should trigger callback) + registry.addComponent(eid, Position); + expect(addCallCount).toBe(1); + + // Try to add same component again (should not trigger callback) + registry.addComponent(eid, Position); + expect(addCallCount).toBe(1); + }); + + it('should handle multiple callbacks for same component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + // Setup multiple callbacks + const callback1Calls: number[] = []; + const callback2Calls: number[] = []; + + registry.onComponentAdd(Position, (eid) => callback1Calls.push(eid)); + registry.onComponentAdd(Position, (eid) => callback2Calls.push(eid)); + + // Add component + registry.addComponent(eid, Position); + + // Both callbacks should be called + expect(callback1Calls).toEqual([eid]); + expect(callback2Calls).toEqual([eid]); + }); + + it('should support unregister functions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const eid = 1; + + const callbackResults: string[] = []; + + // Register multiple callbacks + const unregister1 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback1'); + }); + + const unregister2 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback2'); + }); + + const unregister3 = registry.onComponentAdd(Position, () => { + callbackResults.push('callback3'); + }); + + // Add component - all callbacks should fire + registry.addComponent(eid, Position); + expect(callbackResults).toEqual(['callback1', 'callback2', 'callback3']); + + // Unregister middle callback + unregister2(); + + // Reset and test again + callbackResults.length = 0; + registry.removeComponent(eid, Position); + registry.addComponent(eid, Position); + + // Only callback1 and callback3 should fire + expect(callbackResults).toEqual(['callback1', 'callback3']); + + // Unregister all remaining + unregister1(); + unregister3(); + + // Reset and test again + callbackResults.length = 0; + registry.removeComponent(eid, Position); + registry.addComponent(eid, Position); + + // No callbacks should fire + expect(callbackResults).toEqual([]); + }); + }); +}); + +// 1. Object with Array Properties (Performance Optimized) +type TPosition = { x: number[]; y: number[] }; +type TVelocity = { dx: number[]; dy: number[] }; + +// 2. Array of Objects (Simple but Less Performant) +type TTransform = { x: number; y: number; rotation: number }[]; +type TRenderInfo = { sprite: string; layer: number; visible: boolean }[]; + +// 3. Single Value Array +type THealth = number[]; +type TMana = number[]; +type TLevel = number[]; + +// 4. Tag Components (Markers) +type TPlayer = {}; +type TEnemy = {}; +type TFrozen = {}; diff --git a/packages/feature-ecs/src/component/create-component-registry.ts b/packages/feature-ecs/src/component/create-component-registry.ts new file mode 100644 index 00000000..e8ea9582 --- /dev/null +++ b/packages/feature-ecs/src/component/create-component-registry.ts @@ -0,0 +1,624 @@ +/** + * Component Registry for ECS (Entity Component System) + * + * Provides efficient component management with direct array access for maximum performance. + * Uses sparse arrays for component tracking and bitflags for fast component checks. + * + * Key features: + * - O(1) component checks using bitflags + * - Direct array access for component data + * - Memory-efficient sparse array storage + * - Cache-friendly iteration patterns + * - Unlimited components via generation system + * - Flexible component structure - supports multiple patterns + */ + +import { TEntityId } from '../entity'; +import { TComponentCallbacks, TComponentData, TComponentRef, TUpdateComponentValue } from './types'; + +/** + * Creates a new component registry. + * + * @returns A new component registry instance + * + * @example + * ```typescript + * const registry = createComponentRegistry(); + * + * // Define components using any supported pattern + * const Position: { x: number[]; y: number[] } = { x: [], y: [] }; // Object with arrays (AoS) + * const Transform: { x: number; y: number }[] = []; // Array of objects (SoA) + * const Health: number[] = []; // Single value array + * const Player: {} = {}; // Marker component + * + * // Register all components + * registry.registerComponent(Position); + * registry.registerComponent(Transform); + * registry.registerComponent(Health); + * registry.registerComponent(Player); + * + * const eid = 1; + * + * // Add components and set data + * registry.addComponent(eid, Position); + * Position.x[eid] = 10; + * Position.y[eid] = 20; + * + * registry.addComponent(eid, Transform); + * Transform[eid] = { x: 5, y: 15 }; + * + * registry.addComponent(eid, Health); + * Health[eid] = 100; + * + * registry.addComponent(eid, Player); // Just a flag, no data + * ``` + */ +export function createComponentRegistry(): TComponentRegistry { + return { + _componentMap: new Map(), + _entityMasks: [[]], + _componentCount: 0, + _currentBitflag: 1, + + _addedMasks: [[]], + _changedMasks: [[]], + _removedMasks: [[]], + + _callbacks: new Map(), + _componentsToFlush: new Set(), + + registerComponent(component) { + if (this._componentMap.has(component)) { + return this._componentMap.get(component) as TComponentData; + } + + const componentData: TComponentData = { + id: this._componentCount++, + generationId: this._entityMasks.length - 1, + bitflag: this._currentBitflag, + ref: component + }; + + this._componentMap.set(component, componentData); + + // When we exceed 31 bits, start a new generation + this._currentBitflag *= 2; + if (this._currentBitflag >= 2 ** 31) { + this._currentBitflag = 1; + this._entityMasks.push([]); + this._addedMasks.push([]); + this._changedMasks.push([]); + this._removedMasks.push([]); + } + + return componentData; + }, + + hasComponent(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._entityMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + // Component Addition Flow + // Generation 0: [Position, Velocity, Health, ...] (components 0-30, bitflags 1-2^30) + // Generation 1: [Armor, Weapon, ...] (components 31+, bitflags 1-2^30) + // + // Before: entityMasks: [[<1 empty>, 5, <3 empty>, 2], []] + // addComponent(eid=5, Armor) where Armor.generationId=1, bitflag=1 ↓ + // + // Step 1: Get current mask: entityMasks[1][5] || 0 = 0 + // Step 2: Set component bit: 0 | 1 = 1 + // Step 3: Store new mask: entityMasks[1][5] = 1 + // + // After: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] (entity 5 has Armor) + addComponent( + eid: TEntityId, + component: GComponent, + value?: TUpdateComponentValue + ): void { + // Auto-register component if not already registered + if (!this._componentMap.has(component)) { + this.registerComponent(component); + } + + const componentData = this._componentMap.get(component) as TComponentData; + const { generationId, bitflag } = componentData; + + // Check if entity already has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) !== 0) { + return; + } + + // Set component bit in the appropriate generation + // We don't prefill arrays to create sparse arrays for memory efficiency + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._entityMasks[generationId][eid] = currentMask | bitflag; + + // Track that this component was added this frame + const currentAddedMask = this._addedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._addedMasks[generationId][eid] = currentAddedMask | bitflag; + + // Set component data if value is provided + if (value !== undefined) { + this.updateComponent(eid, component, value, false); + } + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onAdd != null) { + for (const callback of callbacks.onAdd) { + callback(eid); + } + } + this._componentsToFlush.add(component); + }, + + updateComponent( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue, + markAsChanged = true + ): void { + // Array component (SoA): Health[eid] = 100 + if (Array.isArray(component)) { + component[eid] = value; + if (markAsChanged) { + this.markChanged(eid, component); + } + return; + } + + // Marker component (empty object): add/remove based on boolean + if ( + typeof component === 'object' && + component !== null && + Object.keys(component).length === 0 + ) { + if (value === true && !this.hasComponent(eid, component)) { + this.addComponent(eid, component); + } else if (value === false) { + this.removeComponent(eid, component); + } + return; + } + + // Object component (AoS): Position.x[eid] = value.x + if (typeof component === 'object' && component !== null) { + const valueObj = value as Record; + for (const [key, val] of Object.entries(valueObj)) { + const targetArray = (component as Record)[key]; + if (Array.isArray(targetArray)) { + targetArray[eid] = val; + } + } + if (markAsChanged) { + this.markChanged(eid, component); + } + return; + } + }, + + // Component Removal Flow + // Before: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 1]] + // removeComponent(eid=5, Armor) where Armor.generationId=1, bitflag=1 ↓ + // + // Step 1: Get current mask: entityMasks[1][5] = 1 + // Step 2: Check component exists: 1 & 1 = 1 ✓ + // Step 3: Clear component bit: 1 & ~1 = 0 + // Step 4: Clear component data + // + // After: entityMasks: [[<1 empty>, 5, <3 empty>, 2], [<5 empty>, 0]] (entity 5 no longer has Armor) + removeComponent(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + + // Get current mask and check if entity actually has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) === 0) { + return false; + } + + // Track that this component was removed this frame + const currentRemovedMask = this._removedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._removedMasks[generationId][eid] = currentRemovedMask | bitflag; + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onRemove != null) { + for (const callback of callbacks.onRemove) { + callback(eid); + } + } + this._componentsToFlush.add(component); + + // Clear component bit + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._entityMasks[generationId][eid] = currentMask & ~bitflag; + + // Clear component data (AoS or SoA) + if (Array.isArray(component)) { + // Single array component (AoS): delete Health[eid] + delete component[eid]; + } else { + // Object with array properties (SoA): delete Position.x[eid] + for (const key in component) { + if (Array.isArray(component[key])) { + delete component[key][eid]; + } + } + } + + return true; + }, + + removeAllComponents(eid) { + for (const component of this._componentMap.keys()) { + this.removeComponent(eid, component); + } + }, + + markChanged(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + + // Only mark as changed if entity actually has this component + const currentMask = this._entityMasks[generationId]?.[eid] ?? 0; + if ((currentMask & bitflag) === 0) { + return false; + } + + // Track that this component was changed this frame + const currentChangedMask = this._changedMasks[generationId]?.[eid] ?? 0; + // @ts-expect-error - generationId exists because we ensure it when registering the component + this._changedMasks[generationId][eid] = currentChangedMask | bitflag; + + // Fire callbacks if registered + const callbacks = this._callbacks.get(component); + if (callbacks?.onChange != null) { + for (const callback of callbacks.onChange) { + callback(eid); + } + } + this._componentsToFlush.add(component); + + return true; + }, + + wasAdded(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._addedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + wasChanged(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._changedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + wasRemoved(eid, component) { + const componentData = this._componentMap.get(component); + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const mask = this._removedMasks[generationId]?.[eid] ?? 0; + return (mask & bitflag) !== 0; + }, + + onComponentAdd(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onAdd == null) { + componentCallbacks.onAdd = []; + } + componentCallbacks.onAdd.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onAdd?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onAdd?.splice(index, 1); + } + }; + }, + + onComponentChange(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onChange == null) { + componentCallbacks.onChange = []; + } + componentCallbacks.onChange.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onChange?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onChange?.splice(index, 1); + } + }; + }, + + onComponentRemove(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onRemove == null) { + componentCallbacks.onRemove = []; + } + componentCallbacks.onRemove.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onRemove?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onRemove?.splice(index, 1); + } + }; + }, + + onComponentFlush(component, callback) { + if (!this._callbacks.has(component)) { + this._callbacks.set(component, {}); + } + const componentCallbacks = this._callbacks.get(component) as TComponentCallbacks; + if (componentCallbacks.onFlush == null) { + componentCallbacks.onFlush = []; + } + componentCallbacks.onFlush.push(callback); + + // Return unregister function + return () => { + const index = componentCallbacks.onFlush?.indexOf(callback); + if (index != null && index !== -1) { + componentCallbacks.onFlush?.splice(index, 1); + } + }; + }, + + flush() { + // Clear all change tracking for the next frame + for (let generationId = 0; generationId < this._addedMasks.length; generationId++) { + if (this._addedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._addedMasks[generationId].length = 0; + } + if (this._changedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._changedMasks[generationId].length = 0; + } + if (this._removedMasks[generationId] != null) { + // @ts-expect-error - generationId exists because we checked above + this._removedMasks[generationId].length = 0; + } + } + + // Call flush callbacks for components that had changes + for (const component of this._componentsToFlush) { + const callbacks = this._callbacks.get(component); + if (callbacks?.onFlush != null) { + for (const callback of callbacks.onFlush) { + callback(); + } + } + } + + // Clear the set for next frame + this._componentsToFlush.clear(); + }, + + reset() { + // Clear all component data arrays + for (const component of this._componentMap.keys()) { + if (Array.isArray(component)) { + // Single array component + component.length = 0; + } else { + // Object with array properties + for (const key in component) { + if (Array.isArray(component[key])) { + component[key].length = 0; + } + } + } + } + + this._componentMap.clear(); + this._entityMasks.length = 0; + this._entityMasks.push([]); + this._addedMasks.length = 0; + this._addedMasks.push([]); + this._changedMasks.length = 0; + this._changedMasks.push([]); + this._removedMasks.length = 0; + this._removedMasks.push([]); + this._componentCount = 0; + this._currentBitflag = 1; + this._callbacks.clear(); + this._componentsToFlush.clear(); + } + }; +} + +export interface TComponentRegistry { + /** Map of component references to their metadata */ + _componentMap: Map; + /** Array of entity component masks by generation */ + _entityMasks: number[][]; + /** Number of registered components */ + _componentCount: number; + /** Current bitflag value for next component */ + _currentBitflag: number; + + /** Array of added component masks by generation */ + _addedMasks: number[][]; + /** Array of changed component masks by generation */ + _changedMasks: number[][]; + /** Array of removed component masks by generation */ + _removedMasks: number[][]; + + /** Optional callback system for real-time reactions */ + _callbacks: Map; + /** Set of components that need to be flushed for the next frame */ + _componentsToFlush: Set; + + /** + * Registers a component and returns its metadata. + * Uses generation system to support unlimited components. + * @param component - The component to register (can be array or object with arrays) + * @returns Component metadata including ID, generation, and bitflag + */ + registerComponent(component: TComponentRef): TComponentData; + + /** + * Checks if an entity has a specific component. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if entity has the component + */ + hasComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Adds a component to an entity (sets the component bit). + * Component data should be set directly on the component arrays. + * @param eid - The entity ID + * @param component - The component to add + */ + addComponent( + eid: TEntityId, + component: GComponent, + value?: TUpdateComponentValue + ): void; + + /** + * Removes a component from an entity and clears its data. + * @param eid - The entity ID + * @param component - The component to remove + * @returns True if component was removed, false if entity didn't have it + */ + removeComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Removes all components from an entity. + * @param eid - The entity ID + */ + removeAllComponents(eid: TEntityId): void; + + /** + * Marks a component as changed for the current frame. + * @param eid - The entity ID + * @param component - The component to mark as changed + * @returns True if component was marked as changed, false if entity didn't have it + */ + markChanged(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was added to an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was added to the entity in the current frame + */ + wasAdded(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was changed for an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was changed for the entity in the current frame + */ + wasChanged(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if a component was removed from an entity in the current frame. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if component was removed from the entity in the current frame + */ + wasRemoved(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Registers a callback for when a component is added to an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentAdd(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Registers a callback for when a component is changed for an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentChange(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Registers a callback for when a component is removed from an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentRemove(component: TComponentRef, callback: (eid: TEntityId) => void): () => void; + + /** + * Registers a callback for when a component is flushed for an entity. + * @param component - The component to register the callback for + * @param callback - The callback function to register + */ + onComponentFlush(component: TComponentRef, callback: () => void): () => void; + + /** + * Clears all change tracking for the current frame. + */ + flush(): void; + + /** + * Resets the registry to its initial empty state. + */ + reset(): void; + + /** + * Updates component values with type safety. + * - For arrays: sets value directly + * - For marker components (empty objects): true adds component, false removes it + * - For objects with arrays: sets each property value + * @param markAsChanged - Whether to mark the component as changed (default: true) + */ + updateComponent( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue, + markAsChanged?: boolean + ): void; +} diff --git a/packages/feature-ecs/src/component/index.ts b/packages/feature-ecs/src/component/index.ts new file mode 100644 index 00000000..0f091edd --- /dev/null +++ b/packages/feature-ecs/src/component/index.ts @@ -0,0 +1,3 @@ +export * from './create-component-registry'; +export * from './types'; +export * from './validate-component-registry'; diff --git a/packages/feature-ecs/src/component/types.ts b/packages/feature-ecs/src/component/types.ts new file mode 100644 index 00000000..2c1e7507 --- /dev/null +++ b/packages/feature-ecs/src/component/types.ts @@ -0,0 +1,53 @@ +import { TEntityId } from '../entity'; + +export interface TComponentData { + /** Unique component ID */ + id: number; + /** Generation ID (which mask array this component uses) */ + generationId: number; + /** Bitflag for this component (power of 2) */ + bitflag: number; + /** Reference to the component object */ + ref: TComponentRef; +} + +export type TComponentRef = any; // Can be array or object with arrays + +export interface TComponentCallbacks { + onAdd?: ((eid: TEntityId) => void)[]; + onChange?: ((eid: TEntityId) => void)[]; + onRemove?: ((eid: TEntityId) => void)[]; + onFlush?: (() => void)[]; +} + +/** + * Infers the appropriate value type for different component patterns: + * - Marker component: true + * - Object with array properties (AoS): { x: 10, y: 20 } + * - Array of objects (SoA): { x: 10, y: 20 } + * - Single value array: 100 + */ +export type TComponentValue = + GComponent extends Record + ? true // Marker component: {} -> true + : GComponent extends (infer U)[] + ? U // SoA: { x: number; y: number }[] -> { x: number; y: number } or number[] -> number + : GComponent extends Record + ? { [K in keyof GComponent]: GComponent[K] extends (infer U)[] ? U : never } // AoS: { x: number[], y: number[] } -> { x: number, y: number } + : never; + +/** + * Infers the appropriate value type for updateComponent operations: + * - Marker component: boolean (true = add, false = remove) + * - Object with array properties (AoS): Partial<{ x: 10, y: 20 }> (can update subset) + * - Array of objects (SoA): { x: 10, y: 20 } (full object replacement) + * - Single value array: 100 (full value replacement) + */ +export type TUpdateComponentValue = + GComponent extends Record + ? boolean // Marker component: {} -> boolean (true = add, false = remove) + : GComponent extends (infer U)[] + ? U // SoA: { x: number; y: number }[] -> { x: number; y: number } or number[] -> number + : GComponent extends Record + ? Partial<{ [K in keyof GComponent]: GComponent[K] extends (infer U)[] ? U : never }> // AoS: { x: number[], y: number[] } -> { x?: number, y?: number } + : never; diff --git a/packages/feature-ecs/src/component/validate-component-registry.test.ts b/packages/feature-ecs/src/component/validate-component-registry.test.ts new file mode 100644 index 00000000..135af541 --- /dev/null +++ b/packages/feature-ecs/src/component/validate-component-registry.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createEntityIndex, TEntityIndex } from '../entity'; +import { createComponentRegistry, TComponentRegistry } from './create-component-registry'; +import { validateComponentRegistry } from './validate-component-registry'; + +describe('validateComponentRegistry', () => { + let registry: TComponentRegistry; + let entityIndex: TEntityIndex; + + beforeEach(() => { + registry = createComponentRegistry(); + entityIndex = createEntityIndex(); + }); + + it('should return true for valid empty registry', () => { + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true for valid registry with components', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true after component operations', () => { + const Position: TPosition = { x: [], y: [] }; + const Health: THealth = []; + + registry.registerComponent(Position); + registry.registerComponent(Health); + + const eid1 = entityIndex.addEntity(); + const eid2 = entityIndex.addEntity(); + + registry.addComponent(eid1, Position); + registry.addComponent(eid1, Health); + registry.addComponent(eid2, Position); + + expect(validateComponentRegistry(registry)).toBe(true); + + registry.removeComponent(eid1, Health); + expect(validateComponentRegistry(registry)).toBe(true); + + registry.removeAllComponents(eid2); + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true with generation system', () => { + // Register 35 components to test generation overflow + const components = []; + for (let i = 0; i < 35; i++) { + const component = {}; + components.push(component); + registry.registerComponent(component); + } + + expect(validateComponentRegistry(registry)).toBe(true); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, components[0]!); // Gen 0 + registry.addComponent(eid, components[31]!); // Gen 1 + + expect(validateComponentRegistry(registry)).toBe(true); + }); + + it('should return true after reset', () => { + const Position: TPosition = { x: [], y: [] }; + registry.registerComponent(Position); + + const eid = entityIndex.addEntity(); + registry.addComponent(eid, Position); + + registry.reset(); + + expect(validateComponentRegistry(registry)).toBe(true); + }); +}); + +type TPosition = { x: number[]; y: number[] }; +type THealth = number[]; diff --git a/packages/feature-ecs/src/component/validate-component-registry.ts b/packages/feature-ecs/src/component/validate-component-registry.ts new file mode 100644 index 00000000..929857fe --- /dev/null +++ b/packages/feature-ecs/src/component/validate-component-registry.ts @@ -0,0 +1,47 @@ +import { TComponentRegistry } from './create-component-registry'; + +/** + * Validates the internal data structure integrity. + * @returns True if the data structure is valid, false otherwise + */ +export function validateComponentRegistry(registry: TComponentRegistry) { + // Validate generation structure + if (registry._entityMasks.length === 0) { + return false; + } + + // Validate change tracking arrays match entity masks + if ( + registry._addedMasks.length !== registry._entityMasks.length || + registry._changedMasks.length !== registry._entityMasks.length || + registry._removedMasks.length !== registry._entityMasks.length + ) { + return false; + } + + // Validate bitflag consistency within generations + const generationCounts = new Array(registry._entityMasks.length).fill(0); + + for (const componentData of registry._componentMap.values()) { + const { generationId, bitflag } = componentData; + + // Check generation ID is valid + if (generationId >= registry._entityMasks.length || generationId < 0) return false; + + // Check bitflag is a power of 2 and within valid range + if (bitflag <= 0 || bitflag >= 2 ** 31 || (bitflag & (bitflag - 1)) !== 0) return false; + + generationCounts[generationId]++; + } + + // Validate current bitflag matches expected value for current generation + const currentGeneration = registry._entityMasks.length - 1; + const componentsInCurrentGen = generationCounts[currentGeneration] || 0; + const expectedBitflag = componentsInCurrentGen === 0 ? 1 : 2 ** (componentsInCurrentGen % 31); + + if (registry._currentBitflag !== expectedBitflag) { + return false; + } + + return true; +} diff --git a/packages/feature-ecs/src/entity/create-entity-index.test.ts b/packages/feature-ecs/src/entity/create-entity-index.test.ts new file mode 100644 index 00000000..943e3f2b --- /dev/null +++ b/packages/feature-ecs/src/entity/create-entity-index.test.ts @@ -0,0 +1,357 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; + +describe('createEntityIndex', () => { + describe('initialization', () => { + it('should create index with default options', () => { + const index = createEntityIndex(); + + expect(index._aliveCount).toBe(0); + expect(index._dense).toEqual([]); + expect(index._sparse).toEqual([]); + expect(index._nextBaseEid).toBe(1); + expect(index._config.versioning).toBe(false); + expect(index._versionBits).toBe(8); + expect(index._entityBits).toBe(24); + }); + + it('should create index with versioning enabled', () => { + const index = createEntityIndex({ versioning: true, versionBits: 4 }); + + expect(index._config.versioning).toBe(true); + expect(index._versionBits).toBe(4); + expect(index._entityBits).toBe(28); + }); + + it('should validate versionBits range', () => { + expect(() => createEntityIndex({ versionBits: 0 })).toThrow( + 'versionBits must be between 1 and 16' + ); + expect(() => createEntityIndex({ versionBits: 17 })).toThrow( + 'versionBits must be between 1 and 16' + ); + expect(() => createEntityIndex({ versionBits: 1 })).not.toThrow(); + expect(() => createEntityIndex({ versionBits: 16 })).not.toThrow(); + }); + }); + + describe('addEntity', () => { + it('should add first entity with ID 1', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(id).toBe(1); + expect(index._aliveCount).toBe(1); + expect(index._dense).toEqual([1]); + expect(index._sparse[1]).toBe(0); + expect(index._nextBaseEid).toBe(2); + }); + + it('should add multiple entities with sequential IDs', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + expect(id1).toBe(1); + expect(id2).toBe(2); + expect(id3).toBe(3); + expect(index._aliveCount).toBe(3); + expect(index._dense).toEqual([1, 2, 3]); + }); + + it('should recycle removed entity IDs', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + + index.removeEntity(id1); + const recycledId = index.addEntity(); + + expect(recycledId).toBe(id1); + expect(index._aliveCount).toBe(2); + }); + + it('should throw error when exceeding max entities', () => { + // Use maximum versionBits to minimize entity space for testing + const index = createEntityIndex({ versionBits: 16 }); // 16 entity bits, max = 65535 + + // Manually set _nextId to the limit to test the boundary condition + index._nextBaseEid = index._maxBaseEid; // Set to max allowed (65535) + + // This should work (creates entity with ID = maxEid) + const lastValidId = index.addEntity(); + expect(lastValidId).toBe(index._maxBaseEid); + + // This should fail (nextId is now maxEid + 1 = 65536) + expect(() => index.addEntity()).toThrow('Maximum number of entities exceeded'); + }); + }); + + describe('removeEntity', () => { + it('should remove existing entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + const result = index.removeEntity(id); + + expect(result).toBe(true); + expect(index._aliveCount).toBe(0); + expect(index.isEntityAlive(id)).toBe(false); + }); + + it('should return false for non-existent entity', () => { + const index = createEntityIndex(); + + const result = index.removeEntity(999); + + expect(result).toBe(false); + expect(index._aliveCount).toBe(0); + }); + + it('should return false for already removed entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + index.removeEntity(id); + const result = index.removeEntity(id); + + expect(result).toBe(false); + }); + + it('should handle swap-and-pop correctly', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); // Remove middle entity + + expect(index._aliveCount).toBe(2); + expect(index._dense[0]).toBe(id1); + expect(index._dense[1]).toBe(id3); // id3 moved to position 1 + expect(index._sparse[1]).toBe(0); // id1 at position 0 + expect(index._sparse[3]).toBe(1); // id3 at position 1 + }); + + it('should increment version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getBaseEid(recycledId)).toBe(index.getBaseEid(id)); + expect(index.getEidVersion(recycledId)).toBe(1); + expect(recycledId).not.toBe(id); + }); + }); + + describe('isEntityAlive', () => { + it('should return true for alive entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(index.isEntityAlive(id)).toBe(true); + }); + + it('should return false for removed entity', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + index.removeEntity(id); + + expect(index.isEntityAlive(id)).toBe(false); + }); + + it('should return false for non-existent entity', () => { + const index = createEntityIndex(); + + expect(index.isEntityAlive(999)).toBe(false); + }); + + it('should return false for stale versioned entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + index.addEntity(); // Recycle with new version + + expect(index.isEntityAlive(id)).toBe(false); // Old version should be dead + }); + }); + + describe('getEid', () => { + it('should return entity ID without version', () => { + const index = createEntityIndex(); + const id = index.addEntity(); + + expect(index.getBaseEid(id)).toBe(id); + }); + + it('should extract base ID from versioned entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getBaseEid(recycledId)).toBe(index.getBaseEid(id)); + }); + }); + + describe('getEidVersion', () => { + it('should return 0 when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + const id = index.addEntity(); + + expect(index.getEidVersion(id)).toBe(0); + }); + + it('should return 0 for new entity when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + expect(index.getEidVersion(id)).toBe(0); + }); + + it('should return incremented version for recycled entity', () => { + const index = createEntityIndex({ versioning: true }); + const id = index.addEntity(); + + index.removeEntity(id); + const recycledId = index.addEntity(); + + expect(index.getEidVersion(recycledId)).toBe(1); + }); + + it('should handle version overflow', () => { + const index = createEntityIndex({ versioning: true, versionBits: 2 }); // Max version 3 + let currentId = index.addEntity(); + + // Cycle through versions 0, 1, 2, 3, then back to 0 + for (let i = 0; i < 4; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + } + + expect(index.getEidVersion(currentId)).toBe(0); // Wrapped around + }); + }); + + describe('getAliveEntities', () => { + it('should return empty array for new index', () => { + const index = createEntityIndex(); + + expect(index.getAliveEntities()).toEqual([]); + }); + + it('should return all alive entities', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + expect(index.getAliveEntities()).toEqual([id1, id2, id3]); + }); + + it('should not include removed entities', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); + + expect(index.getAliveEntities()).toEqual([id1, id3]); + }); + }); + + describe('formatEid', () => { + it('should format entity ID without version when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + + expect(index.formatEid(id1)).toBe('1'); + expect(index.formatEid(id2)).toBe('2'); + }); + + it('should format entity ID with version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + + expect(index.formatEid(id1)).toBe('1v0'); + expect(index.formatEid(id2)).toBe('2v0'); + }); + + it('should format recycled entity with incremented version', () => { + const index = createEntityIndex({ versioning: true, versionBits: 4 }); + let currentId = index.addEntity(); + + // Cycle through multiple versions + for (let i = 0; i < 10; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + expect(index.formatEid(currentId)).toBe(`1v${i + 1}`); + } + }); + + it('should format entity after version overflow', () => { + const index = createEntityIndex({ versioning: true, versionBits: 2 }); // Max version 3 + let currentId = index.addEntity(); + + // Cycle through versions 0, 1, 2, 3, then back to 0 + for (let i = 0; i < 4; i++) { + index.removeEntity(currentId); + currentId = index.addEntity(); + } + + expect(index.formatEid(currentId)).toBe('1v0'); // Wrapped around + }); + }); + + describe('reset', () => { + it('should reset to initial state and allow reuse', () => { + const index = createEntityIndex({ versioning: true }); + + // Create complex state: entities, removal, recycling + const id1 = index.addEntity(); + const id2 = index.addEntity(); + index.removeEntity(id1); + const recycled = index.addEntity(); + + index.reset(); + + // Verify clean state and reusable + expect(index._aliveCount).toBe(0); + expect(index._dense).toEqual([]); + expect(index._sparse).toEqual([]); + expect(index._nextBaseEid).toBe(1); + + const newId = index.addEntity(); + expect(newId).toBe(1); + expect(index.isEntityAlive(newId)).toBe(true); + }); + }); + + describe('_createVersionedId', () => { + it('should return base ID when versioning disabled', () => { + const index = createEntityIndex({ versioning: false }); + + expect(index._createVersionedEid(5, 3)).toBe(5); + }); + + it('should combine base ID and version when versioning enabled', () => { + const index = createEntityIndex({ versioning: true, versionBits: 8 }); + const baseId = 5; + const version = 3; + + const versionedId = index._createVersionedEid(baseId, version); + + expect(index.getBaseEid(versionedId)).toBe(baseId); + expect(index.getEidVersion(versionedId)).toBe(version); + }); + }); +}); diff --git a/packages/feature-ecs/src/entity/create-entity-index.ts b/packages/feature-ecs/src/entity/create-entity-index.ts new file mode 100644 index 00000000..ed1ca0bb --- /dev/null +++ b/packages/feature-ecs/src/entity/create-entity-index.ts @@ -0,0 +1,302 @@ +/** + * Entity Index for ECS (Entity Component System) + * + * Provides efficient entity ID management with optional versioning support. + * Uses a sparse-dense array pattern for O(1) operations while maintaining + * cache-friendly dense iteration. + * + * Key features: + * - O(1) entity creation, removal, and alive checks + * - Memory-efficient ID recycling + * - Optional versioning to prevent stale entity references + * - Dense array for cache-friendly iteration + */ + +import { TEntityId } from './types'; + +/** + * Creates a new entity index with the specified configuration. + * + * @param options - Configuration options + * @returns A new entity index instance + * + * @example + * ```typescript + * // Basic usage without versioning + * const index = createEntityIndex(); + * const eid = index.addEntity(); + * + * // With versioning enabled + * const versionedIndex = createEntityIndex({ versioning: true }); + * ``` + */ +export function createEntityIndex(options: TCreateEntityIndexOptions = {}): TEntityIndex { + const { versioning = false, versionBits = 8 } = options; + + // Validate configuration + if (versionBits < 1 || versionBits > 16) { + throw new Error('versionBits must be between 1 and 16'); + } + + // Split 32-bit integer between entity ID and version + const entityBits = 32 - versionBits; + const maxBaseEid = (1 << entityBits) - 1; + const entityMask = maxBaseEid; + const versionMask = ((1 << versionBits) - 1) << entityBits; + + return { + _config: { + versioning + }, + + _sparse: [] as number[], + _nextBaseEid: 1, + _dense: [] as number[], + _aliveCount: 0, + + _versionBits: versionBits, + _entityBits: entityBits, + _maxBaseEid: maxBaseEid, + _entityMask: entityMask, + _versionMask: versionMask, + + getBaseEid(eid) { + return eid & this._entityMask; + }, + + getEidVersion(eid) { + return this._config.versioning + ? (eid >>> this._entityBits) & ((1 << this._versionBits) - 1) + : 0; + }, + + // Recycling Flow + // Before: sparse: [_, 0, _, 1] dense: [1, 3, 2v1] aliveCount: 2 + // └─alive─┘└dead┘ + // addEntity() - Recycling Path ↓ + // + // Step 1: Check: 2 < 3 ✓ (dead entities available) + // Step 2: Get recycled entity: dense[2] = 2v1, baseEid = 2 + // Step 3: Restore mapping: _sparse[2] = 2 + // Step 4: Expand alive section: aliveCount = 3 + // + // After: sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ + // Returns: 2v1 (recycled entity with incremented version) + // + // + // New Entity Flow + // Before: sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ nextBaseEid: 4 + // addEntity() - New Entity Path ↓ + // + // Step 1: Check: 3 < 3 ❌ (no dead entities) + // Step 2: Check: 4 <= maxBaseEid ✓ (within limits) + // Step 3: Create: baseEid = 4, eid = 4v0, _nextBaseEid = 5 + // Step 4: Add to arrays: push to dense, set sparse mapping + // + // After: sparse: [_, 0, 2, 1, 3] dense: [1, 3, 2v1, 4] aliveCount: 4 + // └───alive────┘ nextBaseEid: 5 + // Returns: 4 (new entity with version 0) + addEntity() { + // Try to recycle a removed entity first + if (this._aliveCount < this._dense.length) { + const recycledEid = this._dense[this._aliveCount] as number; + const baseEid = this.getBaseEid(recycledEid); + + // Restore the sparse mapping and increment alive count + this._sparse[baseEid] = this._aliveCount; + this._aliveCount++; + return recycledEid; + } + + // Check if we've reached the maximum number of entities + if (this._nextBaseEid > this._maxBaseEid) { + throw new Error(`Maximum number of entities exceeded (${this._maxBaseEid})`); + } + + // Create new entity with version 0 + const baseEid = this._nextBaseEid++; + const eid = this._createVersionedEid(baseEid, 0); + + // Add to both dense and sparse arrays + this._dense.push(eid); + this._sparse[baseEid] = this._aliveCount; + this._aliveCount++; + + return eid; + }, + + // Initial: sparse: [_, 0, 1, 2] dense: [1, 2, 3] aliveCount: 3 + // Remove entity 2 ↓ + // + // Step 1: Find entity 2 at index 1 + // Step 2: Swap entity 3 to index 1 + // sparse: [_, 0, 1, 1] dense: [1, 3, 3] aliveCount: 3 + // + // Step 3: Create recycled entity 2v1 + // Step 4: Place in dead section, clean up sparse + // sparse: [_, 0, _, 1] dense: [1, 3, 2v1] aliveCount: 2 + // └─alive─┘└dead┘ + // + // Later: Recycle entity 2v1 + // sparse: [_, 0, 2, 1] dense: [1, 3, 2v1] aliveCount: 3 + // └──alive───┘ + removeEntity(eid) { + const baseEid = this.getBaseEid(eid); + const denseIndex = this._sparse[baseEid]; + + // Check if entity exists and is alive + if (denseIndex == null || denseIndex >= this._aliveCount || this._dense[denseIndex] !== eid) { + return false; + } + + const lastIndex = this._aliveCount - 1; + + // Swap-and-pop: move the last alive entity to fill the gap + if (denseIndex !== lastIndex) { + const lastEid = this._dense[lastIndex] as number; + const lastBaseEid = this.getBaseEid(lastEid); + + this._dense[denseIndex] = lastEid; + this._sparse[lastBaseEid] = denseIndex; + } + + // Increment version to invalidate old references and prepare for recycling + const currentVersion = this.getEidVersion(eid); + const newVersion = this._config.versioning + ? (currentVersion + 1) & ((1 << this._versionBits) - 1) + : 0; + const recycledEid = this._createVersionedEid(baseEid, newVersion); + + // Place recycled entity in the "dead" section and clean up + this._dense[lastIndex] = recycledEid; + delete this._sparse[baseEid]; + this._aliveCount--; + + return true; + }, + + isEntityAlive(eid) { + const baseEid = this.getBaseEid(eid); + const denseIndex = this._sparse[baseEid]; + return denseIndex != null && denseIndex < this._aliveCount && this._dense[denseIndex] === eid; + }, + + getAliveEntities() { + return this._dense.slice(0, this._aliveCount); + }, + + formatEid(eid) { + const baseEid = this.getBaseEid(eid); + const version = this.getEidVersion(eid); + return this._config.versioning ? `${baseEid}v${version}` : `${baseEid}`; + }, + + reset() { + this._sparse.length = 0; + this._dense.length = 0; + this._aliveCount = 0; + this._nextBaseEid = 1; + }, + + _createVersionedEid(baseEid, version) { + return this._config.versioning ? baseEid | (version << this._entityBits) : baseEid; + } + }; +} + +export interface TCreateEntityIndexOptions { + /** Enable versioning to prevent stale entity references. Default: false */ + versioning?: boolean; + /** Number of bits reserved for version information. Default: 8 (allows 256 versions) */ + versionBits?: number; +} + +export interface TEntityIndex { + _config: { + /** Whether versioning is enabled */ + versioning: boolean; + }; + + /** Sparse array mapping base entity IDs to their index in the dense array */ + _sparse: number[]; + /** The next base entity ID to be assigned */ + _nextBaseEid: number; + /** Dense array of entity IDs for efficient iteration */ + _dense: number[]; + /** Number of currently alive entities */ + _aliveCount: number; + + /** Number of bits reserved for version information */ + _versionBits: number; + /** Number of bits used for entity ID */ + _entityBits: number; + /** Maximum base entity ID that can be assigned */ + _maxBaseEid: number; + /** Bit mask for extracting entity ID */ + _entityMask: number; + /** Bit mask for extracting version */ + _versionMask: number; + + /** + * Creates a new entity ID or recycles a previously removed one. + * @returns A unique entity ID (potentially versioned) + */ + addEntity(): TEntityId; + + /** + * Removes an entity from the index, making its ID available for recycling. + * If versioning is enabled, increments the version to invalidate stale references. + * @param eid - The entity ID to remove + * @returns True if the entity was removed, false if it wasn't alive + */ + removeEntity(eid: TEntityId): boolean; + + /** + * Checks if an entity ID is currently alive. + * @param eid - The entity ID to check + * @returns True if the entity is alive, false otherwise + */ + isEntityAlive(eid: TEntityId): boolean; + + /** + * Extracts the base entity ID without version information. + * @param eid - The potentially versioned entity ID + * @returns The base entity ID (without version bits) + */ + getBaseEid(eid: TEntityId): number; + + /** + * Extracts the version from an entity ID. + * @param eid - The entity ID + * @returns The version number (0 if versioning is disabled) + */ + getEidVersion(eid: TEntityId): number; + + /** + * Gets all alive entity IDs for iteration. + * @returns Array of alive entity IDs + */ + getAliveEntities(): TEntityId[]; + + /** + * Formats an entity ID as a human-readable string. + * @param eid - The entity ID to format + * @returns Formatted string like "1v0", "2v3", or just "1" if versioning disabled + */ + formatEid(eid: TEntityId): string; + + /** + * Resets the entity index to its initial empty state. + */ + reset(): void; + + /** + * Creates a versioned entity ID by combining base ID and version. + * @param baseEid - The base entity ID + * @param version - The version number + * @returns The versioned entity ID + */ + _createVersionedEid(baseEid: number, version: number): number; +} diff --git a/packages/feature-ecs/src/entity/debug-entity-index.test.ts b/packages/feature-ecs/src/entity/debug-entity-index.test.ts new file mode 100644 index 00000000..8d265036 --- /dev/null +++ b/packages/feature-ecs/src/entity/debug-entity-index.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; +import { debugEntityIndex } from './debug-entity-index'; + +describe('debugEntityIndex', () => { + it('should show empty state for new index', () => { + const index = createEntityIndex({ versioning: true }); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (0): []'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {}'); + expect(state).toContain('NextBaseEid: 1'); + expect(state).toContain('Versioning: enabled'); + }); + + it('should show alive entities', () => { + const index = createEntityIndex({ versioning: true }); + index.addEntity(); + index.addEntity(); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (2): [1v0, 2v0]'); + expect(state).toContain('Dead (0): []'); + expect(state).toContain('Sparse: {1→0, 2→1}'); + }); + + it('should show dead entities after removal', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + index.removeEntity(id1); + const state = debugEntityIndex(index); + + expect(state).toContain('Alive (1): [2v0]'); + expect(state).toContain('Dead (1): [1v1]'); + expect(state).toContain('Sparse: {2→0}'); + }); +}); diff --git a/packages/feature-ecs/src/entity/debug-entity-index.ts b/packages/feature-ecs/src/entity/debug-entity-index.ts new file mode 100644 index 00000000..f48c6317 --- /dev/null +++ b/packages/feature-ecs/src/entity/debug-entity-index.ts @@ -0,0 +1,32 @@ +import { TEntityIndex } from './create-entity-index'; + +/** + * Returns a human-readable debug representation of the entity index state. + * Shows alive entities, dead entities, sparse mappings, and configuration. + * @returns Multi-line string with formatted state information + */ +export function debugEntityIndex(entityIndex: TEntityIndex) { + const aliveEntities = entityIndex._dense + .slice(0, entityIndex._aliveCount) + .map((eid) => entityIndex.formatEid(eid)); + const deadEntities = entityIndex._dense + .slice(entityIndex._aliveCount) + .map((eid) => entityIndex.formatEid(eid)); + + const sparseEntries = []; + for (let baseEid = 1; baseEid < entityIndex._nextBaseEid; baseEid++) { + const denseIndex = entityIndex._sparse[baseEid]; + if (denseIndex != null) { + sparseEntries.push(`${baseEid}→${denseIndex}`); + } + } + + return [ + `EntityIndex State:`, + ` Alive (${entityIndex._aliveCount}): [${aliveEntities.join(', ')}]`, + ` Dead (${entityIndex._dense.length - entityIndex._aliveCount}): [${deadEntities.join(', ')}]`, + ` Sparse: {${sparseEntries.join(', ')}}`, + ` NextBaseEid: ${entityIndex._nextBaseEid}`, + ` Versioning: ${entityIndex._config.versioning ? 'enabled' : 'disabled'}` + ].join('\n'); +} diff --git a/packages/feature-ecs/src/entity/index.ts b/packages/feature-ecs/src/entity/index.ts new file mode 100644 index 00000000..a35f8b9c --- /dev/null +++ b/packages/feature-ecs/src/entity/index.ts @@ -0,0 +1,4 @@ +export * from './create-entity-index'; +export * from './debug-entity-index'; +export * from './types'; +export * from './validate-entity-index'; diff --git a/packages/feature-ecs/src/entity/types.ts b/packages/feature-ecs/src/entity/types.ts new file mode 100644 index 00000000..defd4050 --- /dev/null +++ b/packages/feature-ecs/src/entity/types.ts @@ -0,0 +1 @@ +export type TEntityId = number; diff --git a/packages/feature-ecs/src/entity/validate-entity-index.test.ts b/packages/feature-ecs/src/entity/validate-entity-index.test.ts new file mode 100644 index 00000000..c8340aa3 --- /dev/null +++ b/packages/feature-ecs/src/entity/validate-entity-index.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { createEntityIndex } from './create-entity-index'; +import { validateEntityIndex } from './validate-entity-index'; + +describe('validateEntityIndex', () => { + it('should return true for valid empty index', () => { + const index = createEntityIndex(); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true for valid index with entities', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.addEntity(); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after remove operations', () => { + const index = createEntityIndex(); + const id1 = index.addEntity(); + const id2 = index.addEntity(); + const id3 = index.addEntity(); + + index.removeEntity(id2); + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after recycling', () => { + const index = createEntityIndex({ versioning: true }); + const id1 = index.addEntity(); + + index.removeEntity(id1); + index.addEntity(); // Recycle + + expect(validateEntityIndex(index)).toBe(true); + }); + + it('should return true after reset', () => { + const index = createEntityIndex(); + index.addEntity(); + index.addEntity(); + index.removeEntity(index.addEntity()); + + index.reset(); + + expect(validateEntityIndex(index)).toBe(true); + }); +}); diff --git a/packages/feature-ecs/src/entity/validate-entity-index.ts b/packages/feature-ecs/src/entity/validate-entity-index.ts new file mode 100644 index 00000000..6044deac --- /dev/null +++ b/packages/feature-ecs/src/entity/validate-entity-index.ts @@ -0,0 +1,36 @@ +import { TEntityIndex } from './create-entity-index'; + +/** + * Validates the internal data structure integrity. + * Useful for debugging and testing. + * @returns True if the data structure is valid, false otherwise + */ +export function validateEntityIndex(entityIndex: TEntityIndex) { + // Check that all alive entities have correct sparse mappings (Dense -> Sparse) + for (let i = 0; i < entityIndex._aliveCount; i++) { + const eid = entityIndex._dense[i] as number; + const baseEid = entityIndex.getBaseEid(eid); + if (entityIndex._sparse[baseEid] !== i) { + return false; + } + } + + // Check that all entities in sparse array point to valid positions (Sparse -> Dense) + for (let baseEid = 1; baseEid < entityIndex._nextBaseEid; baseEid++) { + const denseIndex = entityIndex._sparse[baseEid]; + if (denseIndex != null) { + // Check bounds + if (denseIndex >= entityIndex._dense.length || denseIndex < 0) { + return false; + } + + // Check that the entity at this position has the correct base ID + const storedEid = entityIndex._dense[denseIndex] as number; + if (entityIndex.getBaseEid(storedEid) !== baseEid) { + return false; + } + } + } + + return true; +} diff --git a/packages/feature-ecs/src/index.ts b/packages/feature-ecs/src/index.ts new file mode 100644 index 00000000..644bfb7f --- /dev/null +++ b/packages/feature-ecs/src/index.ts @@ -0,0 +1,4 @@ +export * from './component'; +export * from './entity'; +export * from './query'; +export * from './world'; diff --git a/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts b/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts new file mode 100644 index 00000000..ce124c46 --- /dev/null +++ b/packages/feature-ecs/src/query/categorize-evaluation-strategy.ts @@ -0,0 +1,49 @@ +import { TQueryFilter } from './types'; + +/** + * Pre-categorizes a query's evaluation strategy. + * + * Strategies: + * - 'bitmask': All filters can use bitwise operations (With/Without/Added/Changed/Removed) + * - 'individual': Contains complex nested filters requiring individual evaluation + */ +export function categorizeEvaluationStrategy(filter: TQueryFilter): 'bitmask' | 'individual' { + switch (filter.type) { + case 'With': + case 'Without': + case 'Added': + case 'Changed': + case 'Removed': + // Simple component and change detection filters are bitmask-compatible + return 'bitmask'; + + case 'And': + // And is bitmask-compatible if ALL children are bitmask-compatible + // Nested And filters work because And(And(A,B),C) === And(A,B,C) logically + return filter.filters.every((f) => categorizeEvaluationStrategy(f) === 'bitmask') + ? 'bitmask' + : 'individual'; + + case 'Or': + // Or is bitmask-compatible ONLY for simple component/change filters + // + // Why Or doesn't support nested And/Or: + // - Or(And(A,B), C) cannot be flattened to simple bitmasks + // - Would require complex mask structures: { andGroups: [..], .. } + // - The performance benefit diminishes while code complexity explodes + return filter.filters.every( + (f) => + f.type === 'With' || + f.type === 'Without' || + f.type === 'Added' || + f.type === 'Changed' || + f.type === 'Removed' + ) + ? 'bitmask' + : 'individual'; + + default: + // Unknown filter types default to individual evaluation + return 'individual'; + } +} diff --git a/packages/feature-ecs/src/query/create-query-registry.test.ts b/packages/feature-ecs/src/query/create-query-registry.test.ts new file mode 100644 index 00000000..c30b0d3b --- /dev/null +++ b/packages/feature-ecs/src/query/create-query-registry.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createWorld, TWorld } from '../world'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; +import { Entity } from './types'; + +describe('createQueryRegistry', () => { + let world: TWorld; + + beforeEach(() => { + world = createWorld(); + }); + + describe('queryEntities', () => { + it('should return matching entities', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + + const result = world._queryRegistry.queryEntities(With(Position)); + expect(result).toEqual([eid1]); + }); + + it('should return empty array when no entities match', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const result = world._queryRegistry.queryEntities(With(Position)); + expect(result).toEqual([]); + }); + + it('should handle complex filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + const result = world._queryRegistry.queryEntities(And(With(Position), With(Health))); + expect(result).toEqual([eid1]); + }); + + it('should use cached results when not dirty', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // First execution should cache result + const result1 = world._queryRegistry.queryEntities(With(Position)); + expect(result1).toEqual([eid]); + + // Second execution should use cache + const result2 = world._queryRegistry.queryEntities(With(Position)); + expect(result2).toEqual([eid]); + }); + + it('should rebuild when cache is dirty', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + world.addComponent(eid1, Position); + + // First execution + const result1 = world._queryRegistry.queryEntities(With(Position)); + expect(result1).toEqual([eid1]); + + // Add another entity (makes cache dirty) + const eid2 = world.createEntity(); + world.addComponent(eid2, Position); + + // Should rebuild with new entity + const result2 = world._queryRegistry.queryEntities(With(Position)); + expect(result2.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should bypass cache when cache=false', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // Query with cache enabled + const result1 = world._queryRegistry.queryEntities(With(Position)); + expect(result1).toEqual([eid]); + + // Add another entity + const eid2 = world.createEntity(); + world.addComponent(eid2, Position); + + // Query with cache disabled - should always rebuild + const result2 = world._queryRegistry.queryEntities(With(Position), { cache: false }); + expect(result2.sort()).toEqual([eid, eid2].sort()); + }); + + it('should respect evaluationStrategy option', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); + + const filter = And(With(Position), With(Health)); + + // Force individual evaluation + const individualResult = world._queryRegistry.queryEntities(filter, { + evaluationStrategy: 'individual' + }); + + // Force bitmask evaluation + const bitmaskResult = world._queryRegistry.queryEntities(filter, { + evaluationStrategy: 'bitmask' + }); + + expect(individualResult).toEqual([eid]); + expect(bitmaskResult).toEqual([eid]); + expect(individualResult).toEqual(bitmaskResult); + }); + }); + + describe('queryComponents', () => { + it('should query components with Entity ID', () => { + // Create components with proper typing + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = [] as { x: number; y: number }[]; + const Health = [] as number[]; + const Player = {}; + + // Create entities + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Add components + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + world.addComponent(eid1, Health); + world.addComponent(eid1, Player); + Position.x[eid1] = 10; + Position.y[eid1] = 5; + Velocity[eid1] = { x: 0, y: 0 }; + Health[eid1] = 100; + + world.addComponent(eid2, Position); + world.addComponent(eid2, Velocity); + world.addComponent(eid2, Health); + Position.x[eid2] = 20; + Position.y[eid2] = 15; + Velocity[eid2] = { x: 10, y: 0 }; + Health[eid2] = 75; + + world.addComponent(eid3, Health); + Health[eid3] = 50; + + // Query with Entity ID + const results = world._queryRegistry.queryComponents([ + Entity, + Position, + Velocity, + Health + ] as const); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual([eid1, { x: 10, y: 5 }, { x: 0, y: 0 }, 100]); + expect(results[1]).toEqual([eid2, { x: 20, y: 15 }, { x: 10, y: 0 }, 75]); + }); + + it('should query with filters', () => { + const Health = [] as number[]; + const Player = {}; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + world.addComponent(eid1, Health); + world.addComponent(eid1, Player); + Health[eid1] = 100; + + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + Health[eid2] = 75; + + world.addComponent(eid3, Health); + Health[eid3] = 50; + + // Query only players + const playerResults = world._queryRegistry.queryComponents( + [Entity, Health] as const, + With(Player) + ); + expect(playerResults).toHaveLength(1); + expect(playerResults[0]).toEqual([eid1, 100]); + + // Query entities without Player marker + const nonPlayerResults = world._queryRegistry.queryComponents( + [Entity, Health] as const, + Without(Player) + ); + expect(nonPlayerResults).toHaveLength(2); + expect(nonPlayerResults).toContainEqual([eid2, 75]); + expect(nonPlayerResults).toContainEqual([eid3, 50]); + }); + + it('should handle single array components', () => { + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Health); + world.addComponent(eid2, Health); + Health[eid1] = 100; + Health[eid2] = 75; + + const results = world._queryRegistry.queryComponents([Entity, Health] as const); + + expect(results).toHaveLength(2); + expect(results[0]).toEqual([eid1, 100]); + expect(results[1]).toEqual([eid2, 75]); + }); + + it('should handle marker components', () => { + const Player = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Player); + + const results = world._queryRegistry.queryComponents([Entity, Player] as const); + + expect(results).toHaveLength(1); + expect(results[0]).toEqual([eid1, true]); + }); + + it('should exclude entities without all components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // eid1 has both Position and Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + Position.x[eid1] = 10; + Position.y[eid1] = 5; + Velocity.x[eid1] = 2; + Velocity.y[eid1] = 1; + + // eid2 has only Position + world.addComponent(eid2, Position); + Position.x[eid2] = 20; + Position.y[eid2] = 15; + + const results = world._queryRegistry.queryComponents([Entity, Position, Velocity] as const); + + // Only eid1 should be included + expect(results).toHaveLength(1); + expect(results[0]).toEqual([eid1, { x: 10, y: 5 }, { x: 2, y: 1 }]); + }); + }); + + describe('getQuery', () => { + it('should create and cache query data', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queryData = world._queryRegistry.getQuery(With(Position)); + + expect(queryData.filter.type).toBe('With'); + expect(typeof queryData.hash).toBe('string'); + expect(queryData.hash.length).toBeGreaterThan(0); + expect(Array.isArray(queryData.cachedResult)).toBe(true); + expect(typeof queryData.isDirty).toBe('boolean'); + }); + + it('should return same cached query for identical filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queryData1 = world._queryRegistry.getQuery(With(Position)); + const queryData2 = world._queryRegistry.getQuery(With(Position)); + + expect(queryData1).toBe(queryData2); + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + + it('should create separate entries for different filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const query1 = world._queryRegistry.getQuery(With(Position)); + const query2 = world._queryRegistry.getQuery(With(Health)); + + expect(query1).not.toBe(query2); + expect(world._queryRegistry._queryCache.size).toBe(2); + }); + + it('should set correct evaluation strategy by default', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + // Simple And should use bitmask + const simpleQuery = world._queryRegistry.getQuery(And(With(Position), With(Health))); + expect(simpleQuery.evaluationStrategy).toBe('bitmask'); + + // Complex nested should use individual + const complexQuery = world._queryRegistry.getQuery( + Or(And(With(Position), With(Health)), With(Position)) + ); + expect(complexQuery.evaluationStrategy).toBe('individual'); + }); + + it('should respect evaluationStrategy option', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + // Force individual strategy + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health)), { + evaluationStrategy: 'individual' + }); + + expect(queryData.evaluationStrategy).toBe('individual'); + }); + + it('should auto-register components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + // Component should not be registered initially + expect(world._componentRegistry._componentMap.has(Position)).toBe(false); + + // getQuery should auto-register component + world._queryRegistry.getQuery(With(Position)); + expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + }); + }); + + describe('registerQuery', () => { + it('should be an alias for getQuery', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queryData1 = world._queryRegistry.registerQuery(With(Position)); + const queryData2 = world._queryRegistry.getQuery(With(Position)); + + expect(queryData1).toBe(queryData2); + }); + }); + + describe('generateQueryHash', () => { + it('should generate string hashes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const hash = world._queryRegistry.generateQueryHash(With(Position)); + + expect(typeof hash).toBe('string'); + expect(hash.length).toBeGreaterThan(0); + }); + + it('should generate same hash for identical filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const hash1 = world._queryRegistry.generateQueryHash(With(Position)); + const hash2 = world._queryRegistry.generateQueryHash(With(Position)); + + expect(hash1).toBe(hash2); + }); + + it('should generate different hashes for different filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const withHash = world._queryRegistry.generateQueryHash(With(Position)); + const withoutHash = world._queryRegistry.generateQueryHash(Without(Position)); + const addedHash = world._queryRegistry.generateQueryHash(Added(Position)); + const changedHash = world._queryRegistry.generateQueryHash(Changed(Position)); + const removedHash = world._queryRegistry.generateQueryHash(Removed(Position)); + const healthHash = world._queryRegistry.generateQueryHash(With(Health)); + + const hashes = [withHash, withoutHash, addedHash, changedHash, removedHash, healthHash]; + const uniqueHashes = new Set(hashes); + expect(uniqueHashes.size).toBe(hashes.length); + }); + + it('should handle component order consistently', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + + // Order shouldn't matter due to sorting in And + const hash1 = world._queryRegistry.generateQueryHash(And(With(Position), With(Velocity))); + const hash2 = world._queryRegistry.generateQueryHash(And(With(Velocity), With(Position))); + + expect(hash1).toBe(hash2); + }); + }); + + describe('checkEntity', () => { + it('should return true when entity matches query', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); + + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health))); + const result = world._queryRegistry.checkEntity(queryData, eid); + + expect(result).toBe(true); + }); + + it('should return false when entity does not match query', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + // Missing Health component + + const queryData = world._queryRegistry.getQuery(And(With(Position), With(Health))); + const result = world._queryRegistry.checkEntity(queryData, eid); + + expect(result).toBe(false); + }); + + it('should handle non-existent entities', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const queryData = world._queryRegistry.getQuery(With(Position)); + const result = world._queryRegistry.checkEntity(queryData, 999); + + expect(result).toBe(false); + }); + }); + + describe('reset', () => { + it('should clear all cached queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + // Populate cache with multiple queries + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.getQuery(With(Health)); + world._queryRegistry.getQuery(And(With(Position), With(Health))); + + expect(world._queryRegistry._queryCache.size).toBe(3); + + world._queryRegistry.reset(); + + expect(world._queryRegistry._queryCache.size).toBe(0); + }); + + it('should allow normal operation after reset', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + // Use registry, then reset + world._queryRegistry.getQuery(With(Position)); + world._queryRegistry.reset(); + + // Should work normally after reset + const queryData = world._queryRegistry.getQuery(With(Position)); + expect(queryData.filter.type).toBe('With'); + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + }); +}); diff --git a/packages/feature-ecs/src/query/create-query-registry.ts b/packages/feature-ecs/src/query/create-query-registry.ts new file mode 100644 index 00000000..381a0a3d --- /dev/null +++ b/packages/feature-ecs/src/query/create-query-registry.ts @@ -0,0 +1,223 @@ +/** + * Query Registry for ECS + * + * Simple and fast query registry with bitmask optimizations. + */ + +import { TComponentRef } from '../component'; +import { TEntityId } from '../entity'; +import { TWorld } from '../world'; +import { categorizeEvaluationStrategy } from './categorize-evaluation-strategy'; +import { Entity, TEntity, TQueryComponentValue, TQueryData, TQueryFilter } from './types'; + +/** + * Creates a new query registry + */ +export function createQueryRegistry(world: TWorld): TQueryRegistry { + return { + _world: world, + _queryCache: new Map(), + + queryEntities(filter, options = {}) { + const { cache = true, ...getQueryOptions } = options; + const queryData = this.getQuery(filter, getQueryOptions); + + // Return cached result if available and not dirty + if (!queryData.isDirty && cache) { + return queryData.cachedResult; + } + + // Exit if no entities exist + const aliveEntities = this._world._entityIndex.getAliveEntities(); + if (aliveEntities.length === 0) { + queryData.cachedResult = []; + queryData.isDirty = false; + return []; + } + + // Find matching entities + const matchingEntities: TEntityId[] = []; + for (const eid of aliveEntities) { + if (filter.evaluate(this._world, eid, queryData)) { + matchingEntities.push(eid); + } + } + + // Cache results + queryData.cachedResult = matchingEntities; + queryData.isDirty = false; + + return matchingEntities; + }, + + queryComponents( + components: GComponents, + filter?: TQueryFilter + ): TComponentDataTuple[] { + // Get entities that match the filter (or all alive entities if no filter) + const matchingEntities = filter + ? this.queryEntities(filter) + : this._world._entityIndex.getAliveEntities(); + + // For each entity, check if it has all components and get their data + const results: TComponentDataTuple[] = []; + for (const eid of matchingEntities) { + const row: unknown[] = []; + let hasAllComponents = true; + + for (const comp of components) { + if (comp === Entity) { + row.push(eid); + } else { + // Check if entity has this component + if (!this._world._componentRegistry.hasComponent(eid, comp)) { + hasAllComponents = false; + break; + } + + // Get component data directly from the component array/object + let componentData; + if (Array.isArray(comp)) { + // Single array component: Health[eid] + componentData = comp[eid]; + } else if (typeof comp === 'object' && comp !== null) { + // Object with arrays (SoA): Position.x[eid], Position.y[eid] + componentData = {} as Record; + let hasArrayProperties = false; + for (const key in comp) { + if (Array.isArray((comp as Record)[key])) { + componentData[key] = (comp as Record)[key][eid]; + hasArrayProperties = true; + } + } + + // If no array properties found, it's a marker component + if (!hasArrayProperties) { + componentData = true; + } + } else { + // Unsupported component + hasAllComponents = false; + break; + } + + row.push(componentData); + } + } + + // Only include entities that have all requested components + if (hasAllComponents) { + results.push(row as TComponentDataTuple); + } + } + + return results; + }, + + getQuery(filter, options = {}) { + const { evaluationStrategy = categorizeEvaluationStrategy(filter) } = options; + const hash = filter.getHash(this._world); + + // Return cached query if exists + if (this._queryCache.has(hash)) { + return this._queryCache.get(hash) as TQueryData; + } + + // Create new query data + const queryData: TQueryData = { + hash, + filter, + evaluationStrategy, + cachedResult: [], + isDirty: true, + generations: [] + }; + + // Let filter register + if (filter.register != null) { + filter.register(this._world, queryData); + } + + // Cache the query + this._queryCache.set(hash, queryData); + + return queryData; + }, + + registerQuery(filter) { + return this.getQuery(filter); + }, + + generateQueryHash(filter) { + return filter.getHash(this._world); + }, + + checkEntity(queryData, eid) { + // Use the stored filter's evaluate method with the query data + return queryData.filter.evaluate(this._world, eid, queryData); + }, + + reset() { + this._queryCache.clear(); + } + }; +} + +export interface TQueryRegistry { + /** Reference to the world */ + _world: TWorld; + /** Cache of compiled queries by hash */ + _queryCache: Map; + + /** + * Queries entities that match the specified filter and returns only entity IDs. + */ + queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + + /** + * Queries components and returns matching entities with component data. + */ + queryComponents( + components: GComponents, + filter?: TQueryFilter + ): TComponentDataTuple[]; + + /** + * Gets or creates a compiled query + */ + getQuery(filter: TQueryFilter, options?: TGetQueryOptions): TQueryData; + + /** + * Registers a query (alias for getOrCreateQuery) + */ + registerQuery(filter: TQueryFilter): TQueryData; + + /** + * Generates a hash for a query filter + */ + generateQueryHash(filter: TQueryFilter): string; + + /** + * Checks if an entity matches a query + */ + checkEntity(queryData: TQueryData, eid: TEntityId): boolean; + + /** + * Resets the query registry to its initial state + */ + reset(): void; +} + +export interface TGetQueryOptions { + /** Evaluation strategy to use for the query */ + evaluationStrategy?: 'bitmask' | 'individual'; +} + +export interface TExecuteQueryOptions extends TGetQueryOptions { + /** Whether to cache the query result */ + cache?: boolean; +} + +export type TComponentDataTuple = { + [K in keyof GComponents]: TQueryComponentValue; +}; diff --git a/packages/feature-ecs/src/query/index.ts b/packages/feature-ecs/src/query/index.ts new file mode 100644 index 00000000..9874510e --- /dev/null +++ b/packages/feature-ecs/src/query/index.ts @@ -0,0 +1,4 @@ +export * from './categorize-evaluation-strategy'; +export * from './create-query-registry'; +export * from './query-filters'; +export * from './types'; diff --git a/packages/feature-ecs/src/query/query-filters.test.ts b/packages/feature-ecs/src/query/query-filters.test.ts new file mode 100644 index 00000000..a9766f90 --- /dev/null +++ b/packages/feature-ecs/src/query/query-filters.test.ts @@ -0,0 +1,782 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { createWorld, TWorld } from '../world'; +import { Added, And, Changed, Or, Removed, With, Without } from './query-filters'; + +describe('Query Filters', () => { + let world: TWorld; + + beforeEach(() => { + world = createWorld(); + }); + + describe('With filter', () => { + it('should match entities that have the component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Both components + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + + const positionEntities = world.queryEntities(With(Position)); + expect(positionEntities.sort()).toEqual([eid1, eid3].sort()); + + const healthEntities = world.queryEntities(With(Health)); + expect(healthEntities.sort()).toEqual([eid2, eid3].sort()); + }); + + it('should return empty array when no entities have component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const result = world.queryEntities(With(Position)); + expect(result).toEqual([]); + }); + + it('should auto-register components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + expect(world._componentRegistry._componentMap.has(Position)).toBe(false); + world.queryEntities(With(Position)); + expect(world._componentRegistry._componentMap.has(Position)).toBe(true); + }); + }); + + describe('Without filter', () => { + it('should match entities that lack the component', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Both components + + const withoutHealth = world.queryEntities(Without(Health)); + expect(withoutHealth.sort()).toEqual([eid1, eid3].sort()); + + const withoutPosition = world.queryEntities(Without(Position)); + expect(withoutPosition.sort()).toEqual([eid2, eid3].sort()); + }); + + it('should match all entities when component doesnt exist', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const NonExistent = { value: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + + const withoutNonExistent = world.queryEntities(Without(NonExistent)); + expect(withoutNonExistent.sort()).toEqual([eid1, eid2].sort()); + }); + }); + + describe('Added filter', () => { + it('should match entities with components added this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add Position to eid1 (should be tracked as added) + world.addComponent(eid1, Position); + + // Add Health to eid2 (should be tracked as added) + world.addComponent(eid2, Health); + + const addedPosition = world.queryEntities(Added(Position)); + expect(addedPosition).toEqual([eid1]); + + const addedHealth = world.queryEntities(Added(Health)); + expect(addedHealth).toEqual([eid2]); + }); + + it('should return empty after flush clears tracking', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.queryEntities(Added(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.queryEntities(Added(Position)); + expect(afterFlush).toEqual([]); + }); + + it('should track multiple adds in same frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + world.addComponent(eid1, Position); + world.addComponent(eid2, Position); + world.addComponent(eid3, Position); + + const addedPosition = world.queryEntities(Added(Position)); + expect(addedPosition.sort()).toEqual([eid1, eid2, eid3].sort()); + }); + }); + + describe('Changed filter', () => { + it('should match entities with components changed this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components first + world.addComponent(eid1, Position); + world.addComponent(eid2, Health); + + // Clear initial "added" tracking + world.flush(); + + // Mark Position as changed for eid1 + world._componentRegistry.markChanged(eid1, Position); + + const changedPosition = world.queryEntities(Changed(Position)); + expect(changedPosition).toEqual([eid1]); + + const changedHealth = world.queryEntities(Changed(Health)); + expect(changedHealth).toEqual([]); + }); + + it('should clear tracking after flush', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.flush(); + + world._componentRegistry.markChanged(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.queryEntities(Changed(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.queryEntities(Changed(Position)); + expect(afterFlush).toEqual([]); + }); + }); + + describe('Removed filter', () => { + it('should match entities with components removed this frame', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components first + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + // Clear initial "added" tracking + world.flush(); + + // Remove Health from eid1 + world.removeComponent(eid1, Health); + + const removedHealth = world.queryEntities(Removed(Health)); + expect(removedHealth).toEqual([eid1]); + + const removedPosition = world.queryEntities(Removed(Position)); + expect(removedPosition).toEqual([]); + }); + + it('should clear tracking after flush', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.flush(); + + world.removeComponent(eid, Position); + + // Before flush: should find the entity + const beforeFlush = world.queryEntities(Removed(Position)); + expect(beforeFlush).toEqual([eid]); + + // After flush: should be empty + world.flush(); + const afterFlush = world.queryEntities(Removed(Position)); + expect(afterFlush).toEqual([]); + }); + }); + + describe('And filter', () => { + it('should match entities that have ALL specified components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + + // eid2: Position + Health + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + + // eid3: All three + world.addComponent(eid3, Position); + world.addComponent(eid3, Velocity); + world.addComponent(eid3, Health); + + const positionAndVelocity = world.queryEntities(And(With(Position), With(Velocity))); + expect(positionAndVelocity.sort()).toEqual([eid1, eid3].sort()); + + const allThree = world.queryEntities(And(With(Position), With(Velocity), With(Health))); + expect(allThree).toEqual([eid3]); + }); + + it('should support nested And filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Velocity); + world.addComponent(eid, Health); + + // And(And(Position, Velocity), Health) should work + const nestedAnd = world.queryEntities(And(And(With(Position), With(Velocity)), With(Health))); + expect(nestedAnd).toEqual([eid]); + }); + + it('should support And with Without filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // eid1: Position + Health + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // eid2: Position + Health + Enemy + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + + const healthyNonEnemies = world.queryEntities( + And(With(Position), With(Health), Without(Enemy)) + ); + expect(healthyNonEnemies).toEqual([eid1]); + }); + + it('should support And with change detection', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add components + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + + // Clear initial tracking + world.flush(); + + // Mark Health as changed for eid1 + world._componentRegistry.markChanged(eid1, Health); + + const positionWithChangedHealth = world.queryEntities(And(With(Position), Changed(Health))); + expect(positionWithChangedHealth).toEqual([eid1]); + }); + }); + + describe('Or filter', () => { + it('should match entities that have ANY of the specified components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + const eid4 = world.createEntity(); + + // eid1: Position only + world.addComponent(eid1, Position); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Shield only + world.addComponent(eid3, Shield); + + // eid4: No components + + const positionOrHealth = world.queryEntities(Or(With(Position), With(Health))); + expect(positionOrHealth.sort()).toEqual([eid1, eid2].sort()); + + const anyOfThree = world.queryEntities(Or(With(Position), With(Health), With(Shield))); + expect(anyOfThree.sort()).toEqual([eid1, eid2, eid3].sort()); + }); + + it('should support Or with Without filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Enemy = {}; + const Ally = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Enemy + world.addComponent(eid1, Position); + world.addComponent(eid1, Enemy); + + // eid2: Position + Ally + world.addComponent(eid2, Position); + world.addComponent(eid2, Ally); + + // eid3: Position + Health + Enemy + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + world.addComponent(eid3, Enemy); + + // Entities that lack Enemy OR lack Ally + const notEnemyOrNotAlly = world.queryEntities(Or(Without(Enemy), Without(Ally))); + expect(notEnemyOrNotAlly.sort()).toEqual([eid1, eid2, eid3].sort()); // All match since each lacks at least one + }); + + it('should support Or with change detection', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Setup initial state + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); + world.addComponent(eid3, Position); + + // Clear initial tracking + world.flush(); + + // Add Shield to eid1, mark Position as changed for eid2 + world.addComponent(eid1, Shield); + world._componentRegistry.markChanged(eid2, Position); + + const addedShieldOrChangedPosition = world.queryEntities( + Or(Added(Shield), Changed(Position)) + ); + expect(addedShieldOrChangedPosition.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should return empty when no entities match any conditions', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + // Entity has no components + + const result = world.queryEntities(Or(With(Position), With(Health))); + expect(result).toEqual([]); + }); + }); + + describe('Complex filter combinations', () => { + it('should handle And(Or(...), With(...))', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + const Alive = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + const eid4 = world.createEntity(); + + // eid1: Position + Health + Alive + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + world.addComponent(eid1, Alive); + + // eid2: Position + Shield + Alive + world.addComponent(eid2, Position); + world.addComponent(eid2, Shield); + world.addComponent(eid2, Alive); + + // eid3: Position + Health (no Alive) + world.addComponent(eid3, Position); + world.addComponent(eid3, Health); + + // eid4: Shield + Alive (no Position) + world.addComponent(eid4, Shield); + world.addComponent(eid4, Alive); + + // Entities with Position AND (Health OR Shield) AND Alive + const complex = world.queryEntities( + And(With(Position), Or(With(Health), With(Shield)), With(Alive)) + ); + + expect(complex.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should handle Or(And(...), With(...))', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Velocity = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Velocity + world.addComponent(eid1, Position); + world.addComponent(eid1, Velocity); + + // eid2: Health only + world.addComponent(eid2, Health); + + // eid3: Position only (missing Velocity) + world.addComponent(eid3, Position); + + // Entities that have (Position AND Velocity) OR Health + const complex = world.queryEntities(Or(And(With(Position), With(Velocity)), With(Health))); + + expect(complex.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should handle deeply nested filters', () => { + const A = {}; + const B = {}; + const C = {}; + const D = {}; + + const eid = world.createEntity(); + world.addComponent(eid, A); + world.addComponent(eid, B); + world.addComponent(eid, C); + + // And(And(A, B), And(C, Without(D))) + const deeplyNested = world.queryEntities( + And(And(With(A), With(B)), And(With(C), Without(D))) + ); + + expect(deeplyNested).toEqual([eid]); + }); + + it('should handle mixed change detection and regular filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + const Enemy = {}; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // Setup initial state + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + world.addComponent(eid2, Position); + world.addComponent(eid2, Health); + world.addComponent(eid2, Enemy); + + world.addComponent(eid3, Position); + world.addComponent(eid3, Shield); + + // Clear initial tracking + world.flush(); + + // Add Shield to eid1, mark Health as changed for eid2 + world.addComponent(eid1, Shield); + world._componentRegistry.markChanged(eid2, Health); + + // Entities with Position AND (Added Shield OR Changed Health) AND Without Enemy + const complex = world.queryEntities( + And(With(Position), Or(Added(Shield), Changed(Health)), Without(Enemy)) + ); + + // Should match eid1 (has Position + added Shield + not Enemy) + // Should NOT match eid2 (has Position + changed Health but IS Enemy) + expect(complex).toEqual([eid1]); + }); + }); + + describe('Edge cases', () => { + it('should handle empty And filter', () => { + const result = world.queryEntities(And()); + expect(result).toEqual([]); + }); + + it('should handle empty Or filter', () => { + const result = world.queryEntities(Or()); + expect(result).toEqual([]); + }); + + it('should handle single filter in And', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + const result = world.queryEntities(And(With(Position))); + expect(result).toEqual([eid]); + }); + + it('should handle single filter in Or', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + const result = world.queryEntities(Or(With(Position))); + expect(result).toEqual([eid]); + }); + + it('should handle queries with non-existent components', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const NonExistent = { value: [] as number[] }; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + + // With non-existent should return empty + const withNonExistent = world.queryEntities(With(NonExistent)); + expect(withNonExistent).toEqual([]); + + // Without non-existent should return all entities + const withoutNonExistent = world.queryEntities(Without(NonExistent)); + expect(withoutNonExistent).toEqual([eid]); + }); + }); + + describe('Performance and caching', () => { + it('should cache identical queries', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid = world.createEntity(); + world.addComponent(eid, Position); + world.addComponent(eid, Health); + + // Execute same query multiple times + const filter = And(With(Position), With(Health)); + const result1 = world.queryEntities(filter); + const result2 = world.queryEntities(filter); + const result3 = world.queryEntities(filter); + + // Should return consistent results + expect(result1).toEqual([eid]); + expect(result2).toEqual([eid]); + expect(result3).toEqual([eid]); + + // Verify caching occurred + expect(world._queryRegistry._queryCache.size).toBe(1); + }); + + it('should invalidate cache when components change', () => { + const Position = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + world.addComponent(eid1, Position); + + // Initial query + const result1 = world.queryEntities(With(Position)); + expect(result1).toEqual([eid1]); + + // Add component to another entity + world.addComponent(eid2, Position); + + // Query should reflect change + const result2 = world.queryEntities(With(Position)); + expect(result2.sort()).toEqual([eid1, eid2].sort()); + }); + + it('should only invalidate affected queries (selective invalidation)', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Velocity = { x: [] as number[], y: [] as number[] }; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + + // Add Position and Health to eid1 + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // Execute different queries to populate cache + const positionQuery = world.queryEntities(With(Position)); + const healthQuery = world.queryEntities(With(Health)); + const velocityQuery = world.queryEntities(With(Velocity)); + + // Verify initial state + expect(positionQuery).toEqual([eid1]); + expect(healthQuery).toEqual([eid1]); + expect(velocityQuery).toEqual([]); + + // Get query data to check dirty flags + const positionQueryData = world._queryRegistry.registerQuery(With(Position)); + const healthQueryData = world._queryRegistry.registerQuery(With(Health)); + const velocityQueryData = world._queryRegistry.registerQuery(With(Velocity)); + + // Queries should not be dirty after execution + expect(positionQueryData.isDirty).toBe(false); + expect(healthQueryData.isDirty).toBe(false); + expect(velocityQueryData.isDirty).toBe(false); + + // Add Velocity to eid2 - should ONLY affect Velocity query + world.addComponent(eid2, Velocity); + + // Only Velocity query should be marked as dirty + expect(positionQueryData.isDirty).toBe(false); // Should NOT be dirty + expect(healthQueryData.isDirty).toBe(false); // Should NOT be dirty + expect(velocityQueryData.isDirty).toBe(true); // Should be dirty + + // Execute queries to verify results + const newPositionQuery = world.queryEntities(With(Position)); + const newHealthQuery = world.queryEntities(With(Health)); + const newVelocityQuery = world.queryEntities(With(Velocity)); + + expect(newPositionQuery).toEqual([eid1]); // No change + expect(newHealthQuery).toEqual([eid1]); // No change + expect(newVelocityQuery).toEqual([eid2]); // Changed + }); + + it('should invalidate multiple queries when shared component changes', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const eid1 = world.createEntity(); + world.addComponent(eid1, Health); + + // Create multiple queries that depend on Position + const positionOnlyQuery = world.queryEntities(With(Position)); + const positionAndHealthQuery = world.queryEntities(And(With(Position), With(Health))); + const positionOrHealthQuery = world.queryEntities(Or(With(Position), With(Health))); + + // Get query data + const positionOnlyData = world._queryRegistry.registerQuery(With(Position)); + const positionAndHealthData = world._queryRegistry.registerQuery( + And(With(Position), With(Health)) + ); + const positionOrHealthData = world._queryRegistry.registerQuery( + Or(With(Position), With(Health)) + ); + + // All should be clean after execution + expect(positionOnlyData.isDirty).toBe(false); + expect(positionAndHealthData.isDirty).toBe(false); + expect(positionOrHealthData.isDirty).toBe(false); + + // Add Position component - should invalidate all Position-related queries + world.addComponent(eid1, Position); + + // All Position-related queries should be dirty + expect(positionOnlyData.isDirty).toBe(true); + expect(positionAndHealthData.isDirty).toBe(true); + expect(positionOrHealthData.isDirty).toBe(true); + }); + }); + + describe('Bitmask vs Individual evaluation strategies', () => { + it('should use bitmask evaluation for simple filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const queryData = world._queryRegistry.registerQuery(And(With(Position), With(Health))); + expect(queryData.evaluationStrategy).toBe('bitmask'); + }); + + it('should use bitmask evaluation for simple Or filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + + const queryData = world._queryRegistry.registerQuery(Or(With(Position), With(Health))); + expect(queryData.evaluationStrategy).toBe('bitmask'); + }); + + it('should use individual evaluation for complex nested filters', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + // Or(And(...), ...) should fall back to individual evaluation + const queryData = world._queryRegistry.registerQuery( + Or(And(With(Position), With(Health)), With(Shield)) + ); + expect(queryData.evaluationStrategy).toBe('individual'); + }); + + it('should produce same results regardless of evaluation strategy', () => { + const Position = { x: [] as number[], y: [] as number[] }; + const Health = [] as number[]; + const Shield = [] as number[]; + + const eid1 = world.createEntity(); + const eid2 = world.createEntity(); + const eid3 = world.createEntity(); + + // eid1: Position + Health + world.addComponent(eid1, Position); + world.addComponent(eid1, Health); + + // eid2: Shield only + world.addComponent(eid2, Shield); + + // eid3: Nothing + + // Bitmask-compatible query + const bitmaskResult = world.queryEntities(Or(With(Position), With(Shield))); + + // Individual evaluation query (same logic) + const individualResult = world.queryEntities( + Or(And(With(Position)), With(Shield)) // Forces individual evaluation + ); + + expect(bitmaskResult.sort()).toEqual([eid1, eid2].sort()); + expect(individualResult.sort()).toEqual([eid1, eid2].sort()); + }); + }); +}); diff --git a/packages/feature-ecs/src/query/query-filters.ts b/packages/feature-ecs/src/query/query-filters.ts new file mode 100644 index 00000000..04eb796d --- /dev/null +++ b/packages/feature-ecs/src/query/query-filters.ts @@ -0,0 +1,441 @@ +import { TComponentRef } from '../component'; +import { TWorld } from '../world'; +import { TQueryData, TQueryFilter, TQueryParentType } from './types'; + +/** + * Requires entity to have component + */ +export function With(component: T): TQueryFilter { + return { + type: 'With', + component, + + evaluate(world, eid): boolean { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + + if (componentData == null) { + return false; + } + + const { generationId, bitflag } = componentData; + const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + return (entityMask & bitflag) !== 0; + }, + + register(world, queryData, parentType): void { + // Register callbacks to invalidate this query when components are added/removed + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'with', parentType); + }, + + getHash(world): string { + const componentId = getComponentId(world, component); + return `with(${componentId})`; + } + }; +} + +/** + * Requires entity to lack component + */ +export function Without(component: T): TQueryFilter { + return { + type: 'Without', + component, + + evaluate(world, eid): boolean { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + + if (componentData == null) { + return true; + } + + const { generationId, bitflag } = componentData; + const entityMask = registry._entityMasks[generationId]?.[eid] ?? 0; + return (entityMask & bitflag) === 0; + }, + + register(world, queryData, parentType): void { + // Register callbacks to invalidate this query when components are added/removed + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'without', parentType); + }, + + getHash(world): string { + const componentId = getComponentId(world, component); + return `without(${componentId})`; + } + }; +} + +/** + * Checks if component was added this frame + */ +export function Added(component: T): TQueryFilter { + return { + type: 'Added', + component, + + evaluate(world, eid): boolean { + return world._componentRegistry.wasAdded(eid, component); + }, + + register(world, queryData, parentType): void { + // Register callback to invalidate this query when components are added + world._componentRegistry.onComponentAdd(component, () => { + queryData.isDirty = true; + }); + + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'added', parentType); + }, + + getHash(world): string { + const componentId = getComponentId(world, component); + return `added(${componentId})`; + } + }; +} + +/** + * Checks if component was changed this frame + */ +export function Changed(component: T): TQueryFilter { + return { + type: 'Changed', + component, + + evaluate(world, eid): boolean { + return world._componentRegistry.wasChanged(eid, component); + }, + + register(world, queryData, parentType): void { + // Register callback to invalidate this query when components are changed + world._componentRegistry.onComponentChange(component, () => { + queryData.isDirty = true; + }); + + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'changed', parentType); + }, + + getHash(world): string { + const componentId = getComponentId(world, component); + return `changed(${componentId})`; + } + }; +} + +/** + * Checks if component was removed this frame + */ +export function Removed(component: T): TQueryFilter { + return { + type: 'Removed', + component, + + evaluate(world, eid): boolean { + return world._componentRegistry.wasRemoved(eid, component); + }, + + register(world, queryData, parentType): void { + // Register callback to invalidate this query when components are removed + world._componentRegistry.onComponentRemove(component, () => { + queryData.isDirty = true; + }); + + // Register callback to invalidate when change tracking is flushed + world._componentRegistry.onComponentFlush(component, () => { + queryData.isDirty = true; + }); + + // Register the component mask in the appropriate structure + registerComponentMask(world, queryData, component, 'removed', parentType); + }, + + getHash(world): string { + const componentId = getComponentId(world, component); + return `removed(${componentId})`; + } + }; +} + +/** + * Requires all child filters to match (uses fast bitmask checking when possible) + */ +export function And(...filters: TQueryFilter[]): TQueryFilter { + return { + type: 'And', + filters, + + evaluate(world, eid, queryData): boolean { + switch (queryData.evaluationStrategy) { + case 'bitmask': { + const { andMasks, orMasks, generations } = queryData; + const entityMasks = world._componentRegistry._entityMasks; + const addedMasks = world._componentRegistry._addedMasks; + const changedMasks = world._componentRegistry._changedMasks; + const removedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const gen = generations[i] as number; + const entityMask = entityMasks[gen]?.[eid] ?? 0; + + // Check AND requirements (must have ALL) + const andMask = andMasks?.[gen]; + if (andMask != null) { + if (andMask.with != null && (entityMask & andMask.with) !== andMask.with) { + return false; + } + if (andMask.without != null && (entityMask & andMask.without) !== 0) { + return false; + } + if (andMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & andMask.added) !== andMask.added) { + return false; + } + } + if (andMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & andMask.changed) !== andMask.changed) { + return false; + } + } + if (andMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & andMask.removed) !== andMask.removed) { + return false; + } + } + } + + // Check OR requirements (must have ANY within each type) + const orMask = orMasks?.[gen]; + if (orMask != null) { + let hasAnyOR = false; + + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + hasAnyOR = true; + } + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + hasAnyOR = true; + } + if (orMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + hasAnyOR = true; + } + } + if (orMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + hasAnyOR = true; + } + } + if (orMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + hasAnyOR = true; + } + } + + if (!hasAnyOR) { + return false; + } + } + } + + return true; + } + + case 'individual': + return filters.every((filter) => filter.evaluate(world, eid, queryData)); + } + }, + + register(world, queryData): void { + for (const filter of filters) { + if (filter.register != null) { + filter.register(world, queryData, 'And'); + } + } + }, + + getHash(world): string { + const childHashes = filters + .map((f) => f.getHash(world)) + .sort() + .join(','); + return `and(${childHashes})`; + } + }; +} + +/** + * Requires any child filter to match + */ +export function Or(...filters: TQueryFilter[]): TQueryFilter { + return { + type: 'Or', + filters, + + evaluate(world, eid, queryData): boolean { + switch (queryData.evaluationStrategy) { + case 'bitmask': { + const { orMasks, generations } = queryData; + const entityMasks = world._componentRegistry._entityMasks; + const addedMasks = world._componentRegistry._addedMasks; + const changedMasks = world._componentRegistry._changedMasks; + const removedMasks = world._componentRegistry._removedMasks; + + for (let i = 0; i < generations.length; i++) { + const gen = generations[i] as number; + const entityMask = entityMasks[gen]?.[eid] ?? 0; + const orMask = orMasks?.[gen]; + + if (orMask != null) { + if (orMask.with != null && (entityMask & orMask.with) !== 0) { + return true; + } + if (orMask.without != null && (entityMask & orMask.without) !== orMask.without) { + return true; + } + if (orMask.added != null) { + const entityAddedMask = addedMasks[gen]?.[eid] ?? 0; + if ((entityAddedMask & orMask.added) !== 0) { + return true; + } + } + if (orMask.changed != null) { + const entityChangedMask = changedMasks[gen]?.[eid] ?? 0; + if ((entityChangedMask & orMask.changed) !== 0) { + return true; + } + } + if (orMask.removed != null) { + const entityRemovedMask = removedMasks[gen]?.[eid] ?? 0; + if ((entityRemovedMask & orMask.removed) !== 0) { + return true; + } + } + } + } + + return false; + } + + case 'individual': + return filters.some((filter) => filter.evaluate(world, eid, queryData)); + } + }, + + register(world, queryData): void { + for (const filter of filters) { + if (filter.register != null) { + filter.register(world, queryData, 'Or'); + } + } + }, + + getHash(world): string { + const childHashes = filters + .map((f) => f.getHash(world)) + .sort() + .join(','); + return `or(${childHashes})`; + } + }; +} + +// Aliases for convenience +export const All = And; +export const Any = Or; + +/** + * Helper to get component ID, registering if needed + */ +function getComponentId(world: TWorld, component: TComponentRef): number { + const registry = world._componentRegistry; + if (!registry._componentMap.has(component)) { + registry.registerComponent(component); + } + return registry._componentMap.get(component)?.id as number; +} + +/** + * Helper function to register component masks with proper parent type + */ +function registerComponentMask( + world: TWorld, + queryData: TQueryData, + component: TComponentRef, + maskType: 'with' | 'without' | 'added' | 'changed' | 'removed', + parentType: TQueryParentType = 'And' +): void { + const registry = world._componentRegistry; + const componentData = registry._componentMap.get(component); + if (componentData == null) { + return; + } + + const { generationId, bitflag } = componentData; + + // Determine which mask structure to use based on parent type + let targetMasks: 'andMasks' | 'orMasks'; + switch (parentType) { + case 'And': + targetMasks = 'andMasks'; + break; + case 'Or': + targetMasks = 'orMasks'; + break; + } + + // Lazy allocation: only create objects when needed + if (queryData[targetMasks] == null) { + queryData[targetMasks] = {}; + } + if (queryData[targetMasks]![generationId] == null) { + queryData[targetMasks]![generationId] = {}; + } + if (queryData.affectedMasks == null) { + queryData.affectedMasks = {}; + } + + // Add to appropriate mask + queryData[targetMasks]![generationId]![maskType] = + (queryData[targetMasks]![generationId]![maskType] ?? 0) | bitflag; + queryData.affectedMasks[generationId] = (queryData.affectedMasks[generationId] ?? 0) | bitflag; + + // Add to generations array if not already present + if (!queryData.generations.includes(generationId)) { + queryData.generations.push(generationId); + } +} diff --git a/packages/feature-ecs/src/query/types.ts b/packages/feature-ecs/src/query/types.ts new file mode 100644 index 00000000..a762bad4 --- /dev/null +++ b/packages/feature-ecs/src/query/types.ts @@ -0,0 +1,78 @@ +import { TComponentRef, TComponentValue } from '../component'; +import { TEntityId } from '../entity'; +import { TWorld } from '../world'; + +/** + * Special entity symbol for component queries + */ +export const Entity = Symbol('Entity'); +export type TEntity = typeof Entity; + +export interface TQueryData { + /** Unique hash identifying this query filter combination */ + hash: string; + /** The original query filter that was compiled into this data */ + filter: TQueryFilter; + /** + * Pre-computed evaluation strategy for optimal performance: + * - 'bitmask': Fast bitwise operations for component/change filters + * - 'individual': Filter-by-filter evaluation for complex queries + */ + evaluationStrategy: 'bitmask' | 'individual'; + + /** Cached array of entity IDs that match this query */ + cachedResult: TEntityId[]; + /** True when cached results are stale and need re-evaluation */ + isDirty: boolean; + + /** Pre-computed generations array for optimal bitmask iteration */ + generations: number[]; + + /** Combined AND masks for all filter types (AND logic: entity must satisfy ALL requirements) */ + andMasks?: Record< + number, + { + with?: number; // Components entity must HAVE (all) + without?: number; // Components entity must LACK (all) + added?: number; // Components entity ADDED this frame (all) + changed?: number; // Components entity CHANGED this frame (all) + removed?: number; // Components entity REMOVED this frame (all) + } + >; + + /** Combined OR masks for all filter types (OR logic: entity must satisfy AT LEAST ONE per type) */ + orMasks?: Record< + number, + { + with?: number; // Components entity must HAVE (any) + without?: number; // Components entity must LACK (any) + added?: number; // Components entity ADDED this frame (any) + changed?: number; // Components entity CHANGED this frame (any) + removed?: number; // Components entity REMOVED this frame (any) + } + >; + + /** Components that can affect this query - enables O(1) invalidation checks */ + affectedMasks?: Record; +} + +export interface TBaseQueryFilter { + type: string; + evaluate(world: TWorld, eid: TEntityId, queryData: TQueryData): boolean; + register?(world: TWorld, queryData: TQueryData, parentType?: TQueryParentType): void; + getHash(world: TWorld): string; +} + +export type TQueryParentType = Extract; + +export type TQueryFilter = + | (TBaseQueryFilter & { type: 'With'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Without'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Added'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Changed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'Removed'; component: TComponentRef }) + | (TBaseQueryFilter & { type: 'And'; filters: TQueryFilter[] }) + | (TBaseQueryFilter & { type: 'Or'; filters: TQueryFilter[] }); + +export type TQueryComponentValue = + GComponent extends TEntity ? TEntityId : TComponentValue; diff --git a/packages/feature-ecs/src/world.ts b/packages/feature-ecs/src/world.ts new file mode 100644 index 00000000..18f34e42 --- /dev/null +++ b/packages/feature-ecs/src/world.ts @@ -0,0 +1,252 @@ +import { withNew } from '@blgc/utils'; +import { + createComponentRegistry, + TComponentRef, + TComponentRegistry, + TUpdateComponentValue +} from './component'; +import type { TComponentValue } from './component/types'; +import { createEntityIndex, TEntityId, TEntityIndex } from './entity'; +import { + createQueryRegistry, + TComponentDataTuple, + TExecuteQueryOptions, + TQueryFilter, + TQueryRegistry +} from './query'; +import { TEntity } from './query/types'; + +// TODO: +// Events +// Systems +// Resources + +/** + * Creates a new ECS world. + * + * @returns A new world instance with component registry, entity index, and query registry + * + * @example + * ```typescript + * const world = createWorld(); + * + * // Define components + * const Position = { x: [], y: [] }; // AoS pattern + * const Health = []; // Single value array + * const Player = {}; // Marker component + * + * // Create entities and add components with values + * const entity = world.createEntity(); + * world.addComponent(entity, Position, { x: 10, y: 20 }); + * world.addComponent(entity, Health, 100); + * world.addComponent(entity, Player, true); + * + * // Update component values + * world.updateComponent(entity, Position, { x: 15 }); // Partial AoS update (y unchanged) + * world.updateComponent(entity, Health, 90, true); // Update & mark changed + * world.updateComponent(entity, Player, false); // Remove marker component + * world.updateComponent(entity, Player, true); // Add marker component back + * + * // Query entities + * const entities = world.queryEntities(And(With(Position), With(Health))); + * ``` + */ +export function createWorld(): TWorld { + return withNew({ + _componentRegistry: createComponentRegistry(), + _entityIndex: createEntityIndex(), + _queryRegistry: null as any, // Will be set in _new + + _new() { + this._queryRegistry = createQueryRegistry(this); + }, + + createEntity() { + return this._entityIndex.addEntity(); + }, + + destroyEntity(eid) { + this._componentRegistry.removeAllComponents(eid); + this._entityIndex.removeEntity(eid); + }, + + addComponent( + eid: TEntityId, + component: GComponent, + value?: TComponentValue + ): void { + this._componentRegistry.addComponent(eid, component, value); + }, + + updateComponent( + eid: TEntityId, + component: GComponent, + value: TUpdateComponentValue, + markAsChanged?: boolean + ): void { + this._componentRegistry.updateComponent(eid, component, value, markAsChanged); + }, + + removeComponent(eid, component) { + return this._componentRegistry.removeComponent(eid, component); + }, + + hasComponent(eid, component) { + return this._componentRegistry.hasComponent(eid, component); + }, + + markComponentChanged(eid, component) { + return this._componentRegistry.markChanged(eid, component); + }, + + queryEntities(filter, options) { + return this._queryRegistry.queryEntities(filter, options); + }, + + queryComponents(components, filter) { + return this._queryRegistry.queryComponents(components, filter); + }, + + flush() { + this._componentRegistry.flush(); + }, + + reset() { + this._componentRegistry.reset(); + this._entityIndex.reset(); + this._queryRegistry.reset(); + } + }); +} + +export interface TWorld { + /** Component registry for managing component data */ + _componentRegistry: TComponentRegistry; + /** Entity index for managing entity lifecycle */ + _entityIndex: TEntityIndex; + /** Query registry for efficient entity queries */ + _queryRegistry: TQueryRegistry; + + /** + * Creates a new entity and returns its ID. + * @returns The new entity ID + */ + createEntity(): TEntityId; + + /** + * Destroys an entity and removes all its components. + * @param eid - The entity ID to destroy + */ + destroyEntity(eid: TEntityId): void; + + /** + * Adds a component to an entity. + * @param eid - The entity ID + * @param component - The component to add + */ + addComponent(eid: TEntityId, component: TComponentRef): void; + + /** + * Adds a component to an entity with initial data. + * @param eid - The entity ID + * @param component - The component to add + * @param value - Initial component data + */ + addComponent( + eid: TEntityId, + component: GComponent, + value: TComponentValue + ): void; + + /** + * Updates a component for an entity. + * - For arrays: sets value directly + * - For marker components (empty objects): true adds component, false removes it + * - For objects with arrays: sets each property value (supports partial updates) + * @param eid - The entity ID + * @param component - The component to update + * @param value - New component data (partial for AoS, boolean for marker components) + * @param markAsChanged - Whether to mark the component as changed (default: true) + */ + updateComponent( + eid: TEntityId, + component: T, + value: TUpdateComponentValue, + markAsChanged?: boolean + ): void; + + /** + * Removes a component from an entity. + * @param eid - The entity ID + * @param component - The component to remove + * @returns True if component was removed, false if entity didn't have it + */ + removeComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Checks if an entity has a specific component. + * @param eid - The entity ID + * @param component - The component to check + * @returns True if entity has the component + */ + hasComponent(eid: TEntityId, component: TComponentRef): boolean; + + /** + * Marks a component as changed for the current frame. + * @param eid - The entity ID + * @param component - The component to mark as changed + */ + markComponentChanged(eid: TEntityId, component: TComponentRef): void; + + /** + * Queries entities that match the specified filter and returns only entity IDs. + * + * @param filter - The query filter to match entities against + * @param options - Query execution options + * @returns Array of entity IDs that match the filter + * + * @example + * ```typescript + * // Simple component query + * const entities = world.queryEntities(With(Position)); + * + * // Complex query with multiple conditions + * const movingEntities = world.queryEntities( + * And(With(Position), With(Velocity), Without(Dead)) + * ); + * ``` + */ + queryEntities(filter: TQueryFilter, options?: TExecuteQueryOptions): TEntityId[]; + + /** + * Queries components and returns matching entities with component data. + * + * @param components Components to retrieve data from (include Entity for entity ID) + * @param filter Optional filter to restrict results + * @returns Array of component data tuples. Entities without all requested components are excluded. + * @example + * ```ts + * // Query for entities with both Position and Velocity, include entity ID + * const results = world.queryComponents([Entity, Position, Velocity]); + * // Returns: [[eid1, {x: 10, y: 5}, {x: 2, y: 1}], [eid2, {x: 20, y: 15}, {x: 1, y: -1}]] + * + * // Query with filter + * const playerResults = world.queryComponents([Entity, Health], With(Player)); + * // Returns: [[eid1, 100], [eid3, 75]] + * ``` + */ + queryComponents( + components: T, + filter?: TQueryFilter + ): TComponentDataTuple[]; + + /** + * Clears the world. + */ + flush(): void; + + /** + * Resets the world to its initial state. + */ + reset(): void; +} diff --git a/packages/feature-ecs/tsconfig.json b/packages/feature-ecs/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/feature-ecs/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/feature-ecs/tsconfig.prod.json b/packages/feature-ecs/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/feature-ecs/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/feature-ecs/vitest.config.mjs b/packages/feature-ecs/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/feature-ecs/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/feature-fetch/package.json b/packages/feature-fetch/package.json index faff5506..65bb40d2 100644 --- a/packages/feature-fetch/package.json +++ b/packages/feature-fetch/package.json @@ -41,9 +41,9 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "@types/url-parse": "^1.4.11", - "msw": "^2.8.4", + "msw": "^2.8.7", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-form/package.json b/packages/feature-form/package.json index db584aae..65671022 100644 --- a/packages/feature-form/package.json +++ b/packages/feature-form/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-logger/package.json b/packages/feature-logger/package.json index afe70d33..19c01aa3 100644 --- a/packages/feature-logger/package.json +++ b/packages/feature-logger/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/feature-react/package.json b/packages/feature-react/package.json index e0eb497f..b75ef7cf 100644 --- a/packages/feature-react/package.json +++ b/packages/feature-react/package.json @@ -60,8 +60,8 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", - "@types/react": "^19.1.5", + "@types/node": "^22.15.29", + "@types/react": "^19.1.6", "feature-form": "workspace:*", "feature-state": "workspace:*", "react": "^19.1.0", diff --git a/packages/feature-state/package.json b/packages/feature-state/package.json index 7c97d17f..25c9d54f 100644 --- a/packages/feature-state/package.json +++ b/packages/feature-state/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/figma-connect/package.json b/packages/figma-connect/package.json index a9701d54..6fca3973 100644 --- a/packages/figma-connect/package.json +++ b/packages/figma-connect/package.json @@ -60,7 +60,7 @@ "devDependencies": { "@blgc/config": "workspace:*", "@figma/plugin-typings": "^1.113.0", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/google-webfonts-client/package.json b/packages/google-webfonts-client/package.json index f794d207..df1e4bc0 100644 --- a/packages/google-webfonts-client/package.json +++ b/packages/google-webfonts-client/package.json @@ -42,7 +42,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "dotenv": "^16.5.0", "openapi-typescript": "^7.8.0", "rollup-presets": "workspace:*" diff --git a/packages/head-metadata/.github/banner.svg b/packages/head-metadata/.github/banner.svg new file mode 100644 index 00000000..c3335a2f --- /dev/null +++ b/packages/head-metadata/.github/banner.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/packages/head-metadata/README.md b/packages/head-metadata/README.md new file mode 100644 index 00000000..84be33f5 --- /dev/null +++ b/packages/head-metadata/README.md @@ -0,0 +1,79 @@ +

+ head-metadata banner +

+ +

+ + GitHub License + + + NPM bundle minzipped size + + + NPM total downloads + + + Join Discord + +

+ +> Status: Experimental + +`head-metadata` is a typesafe and straightforward utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document. + +## 📖 Usage + +### Extract Metadata from `<head>` + +```ts +import { extractHeadMetadata, linkExtractor, metaExtractor, titleExtractor } from 'head-metadata'; + +const html = ` + <html> + <head> + <title>Example + + + + +`; + +const metadata = extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor +}); + +console.log(metadata); +/* +{ + title: 'Example', + meta: { + description: 'An example page' + }, + link: { + canonical: 'https://example.com' + } +} +*/ +``` + +### Create Custom Extractors + +You can write your own extractors to handle any `` child element: + +```ts +export const customLinkExtractor = { + type: 'collection' as const, + parent: 'link' as const, + callback: (node) => { + const rel = node.attributes.find((a) => a.local === 'rel'); + const href = node.attributes.find((a) => a.local === 'href'); + if (rel != null && href != null) { + return { key: rel.value, value: href.value }; + } + + return null; + } +} satisfies TCollectionExtractor; +``` diff --git a/packages/head-metadata/eslint.config.js b/packages/head-metadata/eslint.config.js new file mode 100644 index 00000000..275e54fa --- /dev/null +++ b/packages/head-metadata/eslint.config.js @@ -0,0 +1,5 @@ +/** + * @see https://eslint.org/docs/latest/use/configure/configuration-files + * @type {import("eslint").Linter.Config} + */ +module.exports = [...require('@blgc/config/eslint/library')]; diff --git a/packages/head-metadata/package.json b/packages/head-metadata/package.json new file mode 100644 index 00000000..2e9bc306 --- /dev/null +++ b/packages/head-metadata/package.json @@ -0,0 +1,49 @@ +{ + "name": "head-metadata", + "version": "0.0.4", + "private": false, + "description": "Typesafe and straightforward utility for extracting structured metadata (like ``, ``, and `<link>`) from the `<head>` of an HTML document", + "keywords": [], + "homepage": "https://builder.group/?source=package-json", + "bugs": { + "url": "https://github.com/builder-group/community/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/builder-group/community.git" + }, + "license": "MIT", + "author": "@bennobuilder", + "main": "./dist/cjs/index.js", + "module": "./dist/esm/index.js", + "source": "./src/index.ts", + "types": "./dist/types/index.d.ts", + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "shx rm -rf dist && rollup -c rollup.config.js", + "build:prod": "export NODE_ENV=production && pnpm build", + "clean": "shx rm -rf dist && shx rm -rf .turbo && shx rm -rf node_modules", + "install:clean": "pnpm run clean && pnpm install", + "lint": "eslint . --fix", + "publish:patch": "pnpm build:prod && pnpm version patch && pnpm publish --no-git-checks --access=public", + "size": "size-limit --why", + "start:dev": "tsc -w", + "test": "vitest run", + "update:latest": "pnpm update --latest" + }, + "dependencies": { + "xml-tokenizer": "workspace:*" + }, + "devDependencies": { + "@types/node": "^22.15.29", + "rollup-presets": "workspace:*" + }, + "size-limit": [ + { + "path": "dist/esm/index.js" + } + ] +} diff --git a/packages/head-metadata/rollup.config.js b/packages/head-metadata/rollup.config.js new file mode 100644 index 00000000..d09fd346 --- /dev/null +++ b/packages/head-metadata/rollup.config.js @@ -0,0 +1,6 @@ +const { libraryPreset } = require('rollup-presets'); + +/** + * @type {import('rollup').RollupOptions[]} + */ +module.exports = libraryPreset(); diff --git a/packages/head-metadata/src/__tests__/playground.test.ts b/packages/head-metadata/src/__tests__/playground.test.ts new file mode 100644 index 00000000..2d9d4d6f --- /dev/null +++ b/packages/head-metadata/src/__tests__/playground.test.ts @@ -0,0 +1,20 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { extractHeadMetadata } from '../extract-head-metadata'; +import { linkExtractor, metaExtractor, titleExtractor } from '../extractors'; + +describe('playground', () => { + it('should work', async () => { + const filePath = join(__dirname, './resources/e2e/bsky.html'); + const html = await readFile(filePath, 'utf-8'); + + const metadata = await extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor + }); + + expect(metadata).toBeDefined(); + }); +}); diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html new file mode 100644 index 00000000..ea4238fc --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.html @@ -0,0 +1,1038 @@ +<!doctype html> +<html class="no-js" lang="en" itemscope itemtype="http://schema.org/Blog"> +<head> +<meta name="theme-color" content="#000000"> +<link rel="alternate" type="application/rss+xml" title="XML" href="http://bento.com/bentonews.xml" /> +<link rel="search" type="application/opensearchdescription+xml" title="Bento.com Tokyo" href="bentotokyosearch.xml" /> +<link rel="icon" type="image/png" href="/favicon-192y.png" sizes="192x192" /> +<link rel="apple-touch-icon-precomposed" sizes="120x120" href="/apple-touch-icon-120x120.png" /> +<link rel="icon" type="image/png" href="/favicon-96.png" sizes="96x96" /> +<link rel="icon" type="image/png" href="/favicon-32x32.png" sizes="32x32" /> +<link rel="icon" type="image/png" href="/favicon-16x16.png" sizes="16x16" /> +<meta charset="sjis" /> +<meta name="viewport" content="width=device-width, initial-scale=1.0" /> +<meta name="description" content="The Tokyo Food Page is a complete guide to Japanese food and restaurants in Tokyo, featuring recipes, articles on Japanese cooking, restaurant listings, culinary travel tips and more." /> +<meta name="google-site-verification" content="eOJ2O8Jr70PfyLxj7o-KkvXxl8oJKTB-YIFYyOPcgMQ" /> +<meta name="bitly-verification" content="0bcee6f100be"/> +<meta itemprop="name" content="Bento.com Japanese cuisine and restaurant guide " /> +<meta property="og:title" content="Bento.com Japanese cuisine and restaurant guide" /> +<meta property="og:url" content="http://bento.com/tokyofood.html" /> +<meta property="og:site_name" content="Bento.com" /> +<meta property="og:image" content="http://bento.com/pix/icon-1000-400-tokyo2.jpg" /> + +<title>Bento.com Japanese cuisine and restaurant guide + + + + + + + + + + + + + + +
+ +
+ + + + + + + + + + +
+ + + + + +
+
+
+
+
+
+Bento.com +
+
+"Always fresh, never fishy" +
+ + +
+ +
+
+
+
+ + +
+ +
+
+ + +
+ +
+
Share:
+ +
+ + +
+
Follow:
+ +
+ +
+ +
+

Latest reviews

+
+ + + + + +
+ +
+ + +
+
Ageha isn't super-fancy, but they turn out reliable, good-quality kushiage skewers at reasonable prices. There are four prix-fixe menus at both lunch and dinner, with somewhat more premium ingredients as the price level rises. Most full meals comprise eight or ten skewers of vegetables, seafood and meats, plus soup, rice, pickles and dessert. + Ingredients are lightly coated before.... + [Continue reading] +
+
+
+ + + +
+ +
+ + +
+
We'd love this place even if it weren't for the gorgeous bayside view. They serve great food and inspired original cocktails, and they run their own craft brewery, coffee roastery and gin distillery on premises. When the weather is nice you can enjoy the fresh ocean breezes out on their waterfront terrace. + The food menu focuses on casual American classics, among them some of the.... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
Fish is known for their three-curry combo plates, which come with your choice of a main curry (pork, chicken, extra-spicy chicken, and various weekly specials) paired with a mixed-bean curry and a keema. The recommended pork curry is particularly fiery, with a good mix of spices, while the three-bean curry is mild and gently spiced and the keema is rich and meaty. + Curry combos come.... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
Tomato ramen is the unusual specialty here, and a bowl of it incorporates juice and pulp from five and a half tomatoes as well as pork, onions, greens and other ingredients. Tomako's soup is richer and more meaty than other tomato-based ramen bowls we've had, and the thin, firm noodles do a good job soaking up the flavors. + Tomato ramen topped with oven-grilled cheese is the most.... + [Continue reading] +
+
+
+ + + +
+ +
+ + +
+
This standing bar inside a regional antenna shop showcases sake from Toyama, Ishikawa and Fukui Prefectures - collectively known as the Hokuriku region. Between the selection behind the bar and the self-service sake dispensers, they boast the largest collection of Hokuriku sake in Kansai. + When you order at the bar, sake comes in 60ml servings, or three-part tasting flights of 45ml each. .... + [Continue reading] +
+
+
+ + +
+ +
+ + +
+
This lively dining bar specializes in smoked foods and smoky cocktails, and stocks a good selection of whiskies. Homemade smoked items like bacon, cheese, chicken wings and quail eggs are skillfully prepared in the bar's custom smoker, while zucchini fritters, fries and similar dishes are served with the bar's own smoked mayonnaise and smoked ketchup. + One standout, though it's not.... + [Continue reading] +
+
+
+ + + + + +
+ +
+ + +
+

City guides

+
+
+
+
+
+
+ Tokyo +
+
+
+ +
+
+
+
+ Fukuoka +
+
+
+ +
+
+
+
+ Nagoya +
+
+
+ +
+
+
+
+ Yokohama +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ + +
+

Travel tools

+
+ + + +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+
+ Search tips +
+
+ + +
+
+
+
+
+ + +
+
+ + +
+

Exploring Japanese cuisine

+
+ + +
+
+ + +
+ All about ramen, tonkatsu, tempura and grilled chicken on sticks, with menu-reading guides +
+
+
+
+ +
+
+ +
+ Recipes +
+
+ Making Japanese dishes at home +
+
+
+
+ +
+
+ + +
+ Holiday meals, kitchen tools, sake snacks +
+
+
+
+ + + +
+ +
+
+ + +
+ Food-related travels around Japan +
+
+
+
+ +
+
+ + +
+ Kitchen tools, sake snacks, holiday meals +
+
+
+
+ + +
+
+ + + +
+

Articles and special features

+
+ + +
+
+ +
+ +
+ An introduction to different types of sake, plus a glossary of sake terms +
+
+
+
+
+ + +
+
+ + +
+ Menu-reading help for izakaya and sushi shops +
+
+
+
+ + + + + +
+ + +
+
+ + +
+ A combination museum, restaurant complex and mini-theme park, with eight ramen shops to try +
+
+
+
+ + +
+
+ + +
+ Sample regional delicacies and local sake without leaving the capital +
+
+
+
+ + + + +
+
+
+ + +
+
+ +
+ +
+ Learn the secrets of Osaka's favorite octopus snack +
+
+
+
+
+ +
+
+ +
+ +
+ What are style-savvy brewers wearing at festivals and tastings around the country? +
+
+
+
+
+ + + +
+ +
+
+ +
+ +
+ A bustling warren of tiny shops catering to both restaurants and consumers +
+
+
+
+
+ +
+
+ +
+ +
+ Tour of one of Kyoto's luxurious department-store food halls +
+
+
+
+
+ + + +
+
+
+ + + +
+
+ + +
+ Explore Kobe's biggest sake museum, built in an old brewery building +
+
+
+
+ +
+
+ + +
+ Different styles of ramen explained, with a noodle-term glossary +
+
+
+
+ + + +
+ + +
+
+ + +
+ Find the best craft-beer bars in Osaka, Kobe and Kyoto +
+
+
+
+ + +
+
+ + +
+ Finding great pork is easy, but where do you go for great turnips? Here are some suggestions. +
+
+
+
+ + + + +
+

Travel tools

+
+ +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+ +
+
+ +
+
+
+ + + +
+ +
+
+ + +
+
+
+ + + +
+
+ Search tips +
+
+ + +
+
+
+
+
+ + + +
+
+ + + + +
+ + + + +
+ +


+ + + +
+ +
Share:
+ +
+ + +
+ +
Follow:
+ +
+ + +
+
Sister sites:
+
+
+
+
+
Craft Beer Bars Japan
+
+
+
Bars, retailers and festivals
+
+
+
+
+
+
+
Animal Cafes
+
+
+
Cat, rabbit and bird cafe guide
+
+
+
+
+
+
+
Where in Tokyo
+
+
+
Fun things to do in the big city
+
+
+
+
+
+
+
tokyopicks.com
+
+
+
Neighborhood guides and top-five lists from Tokyo experts
+
+
+
+
+
+
+
Barking Inu
+
+
+
Sushi dictionary and Japan Android apps
+
+
+
+
+ +
+
+ + +
+ +
+ + + + + + +
 
+
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+
+ + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json new file mode 100644 index 00000000..ba5d5bf3 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bento-com.json @@ -0,0 +1,22 @@ +{ + "link": { + "alternate": "http://bento.com/bentonews.xml", + "apple-touch-icon-precomposed": "/apple-touch-icon-120x120.png", + "icon": "/favicon-16x16.png", + "search": "bentotokyosearch.xml", + "stylesheet": "css/bento-front-grid.css" + }, + "meta": { + "bitly-verification": "0bcee6f100be", + "charset": "sjis", + "description": "The Tokyo Food Page is a complete guide to Japanese food and restaurants in Tokyo, featuring recipes, articles on Japanese cooking, restaurant listings, culinary travel tips and more.", + "google-site-verification": "eOJ2O8Jr70PfyLxj7o-KkvXxl8oJKTB-YIFYyOPcgMQ", + "og:image": "http://bento.com/pix/icon-1000-400-tokyo2.jpg", + "og:site_name": "Bento.com", + "og:title": "Bento.com Japanese cuisine and restaurant guide", + "og:url": "http://bento.com/tokyofood.html", + "theme-color": "#000000", + "viewport": "width=device-width, initial-scale=1.0" + }, + "title": "Bento.com Japanese cuisine and restaurant guide" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bsky.html b/packages/head-metadata/src/__tests__/resources/e2e/bsky.html new file mode 100644 index 00000000..5396d915 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bsky.html @@ -0,0 +1,196 @@ + + + + + + + + + @bennobuilder.bsky.social on Bluesky + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+
+ + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/bsky.json b/packages/head-metadata/src/__tests__/resources/e2e/bsky.json new file mode 100644 index 00000000..69ee8538 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/bsky.json @@ -0,0 +1,29 @@ +{ + "meta": { + "charset": "UTF-8", + "viewport": "width=device-width, initial-scale=1, minimum-scale=1, viewport-fit=cover", + "referrer": "origin-when-cross-origin", + "application-name": "Bluesky", + "generator": "bskyweb", + "og:site_name": "Bluesky Social", + "og:type": "profile", + "profile:username": "bennobuilder.bsky.social", + "og:url": "https://bsky.app/profile/bennobuilder.bsky.social", + "og:title": "Benno Builder (@bennobuilder.bsky.social)", + "description": "passionate builder :)", + "og:description": "passionate builder :)", + "twitter:card": "summary", + "twitter:label1": "Account DID", + "twitter:value1": "did:plc:hs2ktvfcphpglp6dami7cpxg" + }, + "link": { + "preconnect": "https://bsky.social", + "preload": "https://web-cdn.bsky.app/static/media/InterVariable.c504db5c06caaf7cdfba.woff2", + "stylesheet": "https://web-cdn.bsky.app/static/css/main.b583bf74.css", + "apple-touch-icon": "https://web-cdn.bsky.app/static/apple-touch-icon.png", + "icon": "https://web-cdn.bsky.app/static/favicon-16x16.png", + "mask-icon": "https://web-cdn.bsky.app/static/safari-pinned-tab.svg", + "alternate": "at://did:plc:hs2ktvfcphpglp6dami7cpxg/app.bsky.actor.profile/self" + }, + "title": "@bennobuilder.bsky.social on Bluesky" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html new file mode 100644 index 00000000..a07f6846 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.html @@ -0,0 +1,14 @@ + Google



 

Erweiterte Suche

© 2025 - Datenschutzerkl�rung - Nutzungsbedingungen

\ No newline at end of file diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json new file mode 100644 index 00000000..ee386090 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google-unformatted.json @@ -0,0 +1,15 @@ +{ + "link": {}, + "meta": { + "og:description": "Bundestagswahl 2025! #GoogleDoodle", + "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "og:image:height": "460", + "og:image:width": "1150", + "twitter:card": "summary_large_image", + "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", + "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "twitter:site": "@GoogleDoodles", + "twitter:title": "Bundestagswahl 2025" + }, + "title": "Google" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.html b/packages/head-metadata/src/__tests__/resources/e2e/google.html new file mode 100644 index 00000000..7638a236 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google.html @@ -0,0 +1,920 @@ + + + + + + + + + + + + + + + Google + + + + + + + +
+
+ Suche + Bilder + Maps + Play + YouTube + News + Gmail + Drive + Mehr » +
+ +
+
+
+
+
+
+

+
+
+ + + + + + +
  + +
+ +
+
+ + +
+ Erweiterte Suche +
+ + +
+

+ +

+ © 2025 - Datenschutzerkl�rung - + Nutzungsbedingungen +

+
+ + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/google.json b/packages/head-metadata/src/__tests__/resources/e2e/google.json new file mode 100644 index 00000000..ee386090 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/google.json @@ -0,0 +1,15 @@ +{ + "link": {}, + "meta": { + "og:description": "Bundestagswahl 2025! #GoogleDoodle", + "og:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "og:image:height": "460", + "og:image:width": "1150", + "twitter:card": "summary_large_image", + "twitter:description": "Bundestagswahl 2025! #GoogleDoodle", + "twitter:image": "https://www.google.com/logos/doodles/2025/german-federal-election-2025-6753651837110659-2x.png", + "twitter:site": "@GoogleDoodles", + "twitter:title": "Bundestagswahl 2025" + }, + "title": "Google" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.html b/packages/head-metadata/src/__tests__/resources/e2e/paddle.html new file mode 100644 index 00000000..ab84c14b --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/paddle.html @@ -0,0 +1,1229 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Paddle - Payments, tax and subscription management for SaaS and digital products + + + + + + + + + + + + + +
+

Affected by Digital River winding down?

+ We’ve got you covered in 48 hours +
+ + + + +
+
+ +
+
+

Put your billing operations on autopilot

+
+

As a merchant of record, we manage your payments, tax and compliance needs, so you can focus on growth.

+
+ HubX Logo + MacPaw Logo + Runna Logo + Geoguessr Logo +
+ +
+
+
+ +
+
+
+ +
+ Platform Collage +
+
+
+
+
+ + +
+
+
+ Products +

Your all-in-one payments +infrastructure

+
+
+
+
+ + +
+
+
+
+
+

Billing

+

The only complete billing solution for digital products. Payments, tax, subscription management and more, all handled for you.

+ Discover Billing +
+ SaaS billing dashboard +
+
+
+

ProfitWell Metrics

+

Keep a finger on the pulse of your business with accurate, accessible revenue reporting for subscription and SaaS companies - completely free.

+ Discover ProfitWell Metrics +
+ Metrics SaaS analytics dashboard +
+
+
+

Retain

+

A smarter way to recover failed payments, Retain automatically recovers failed card payments and increases customer retention. Set it up once and we’ll do the rest.

+ Discover Retain +
+ Retain automatic customer retention software +
+
+
+
+ + +
+
+
+ + +
+
+
+ Results +

Over 5,000+ software businesses use Paddle to scale their commercial operations

+
+ +
+
+ + + + + +
+
+
+
+

+ 122 million + transactions processed +

+
+
+

+ $89 million + in sales taxes remitted last year +

+
+
+

+ 5,000+ + customers using Paddle +

+
+
+
+
+ + +
+
+
+ Fortinet + MacPaw + Laravel + Adaptavist + GeoGuessr + n8n.io + tailwind labs + removebg + BeyondCode + Daylite + GETBLOCK +
+
+ Fortinet + MacPaw + Laravel + Adaptavist + GeoGuessr + n8n.io + tailwind labs + removebg + BeyondCode + Daylite + GETBLOCK +
+
+
+ + +
+
+
+ + +
+
+
+ Our model +

How is Paddle different?

+

Paddle provides more than just the plumbing for your revenue. As a merchant of record, we do it for you.

+ + What is a merchant of record? + +
+
+
+ +

Build and maintain relationships with payment providers

+
+
+ +

Take on liability for charging and remitting sales taxes, globally

+
+
+ +

Take on liability for all fraud that takes place on our platform

+
+
+ +

Reconcile your revenue data across billing and payment methods

+
+
+ +

Handle all billing-related support queries for you

+
+
+ +

Reduce churn by recovering failed payments

+
+
+
+
+ + +
+
+
+ +
+

Join 5,000+ businesses already growing with Paddle

+

We built the complete payment stack, so you don‘t have to

+ +
+ +
+
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/paddle.json b/packages/head-metadata/src/__tests__/resources/e2e/paddle.json new file mode 100644 index 00000000..889e0d52 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/paddle.json @@ -0,0 +1,20 @@ +{ + "link": { + "canonical": "https://www.paddle.com", + "preload": "https://js.hsforms.net/forms/v2.js", + "stylesheet": "/_next/static/css/9200dd3d7924dc8e.css" + }, + "meta": { + "description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "og:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "og:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,19,1200,591&w=1280&h=630", + "og:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", + "robots": "index", + "twitter:card": "summary_large_image", + "twitter:description": "We are the merchant of record for B2B & B2C software businesses. Helping increase global conversions, reducing churn, staying compliant, and scaling up fast.", + "twitter:image": "https://images.prismic.io/paddle/a01787c4-75fa-408c-8b63-16a85b255826_paddle-share-image.png?auto=compress,format&rect=0,14,1200,600&w=1024&h=512", + "twitter:title": "Paddle - Payments, tax and subscription management for SaaS and digital products", + "viewport": "width=device-width, initial-scale=1, maximum-scale=5" + }, + "title": "Paddle - Payments, tax and subscription management for SaaS and digital products" +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html new file mode 100644 index 00000000..a91973f4 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.html @@ -0,0 +1,2998 @@ + + + + Starter Story: Learn How People Are Starting Successful Businesses + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+ +
+ + + + +
+
+ + + + + +
+
+
+
+
+ +
Starter Story
+
+
+ Unlock the secrets to 7-figure online businesses +
+
+ Dive into our database of 4,413 case studies & join our community of thousands of + successful founders. +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+ + Join thousands of founders +
+
+ +
+
+ + + + + +
+ + + + + + + + + + + + + + + + +
+ + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json new file mode 100644 index 00000000..ade8b483 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/starterstory.json @@ -0,0 +1,26 @@ +{ + "title": "Starter Story: Learn How People Are Starting Successful Businesses", + "meta": { + "description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "og:title": "Starter Story: Learn How People Are Starting Successful Businesses", + "og:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "og:url": "https://www.starterstory.com", + "og:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", + "og:image:width": "1024", + "og:image:height": "512", + "twitter:card": "summary_large_image", + "twitter:site": "@starter_story", + "twitter:title": "Starter Story: Learn How People Are Starting Successful Businesses", + "twitter:description": "Starter Story interviews successful entrepreneurs and shares the stories behind their businesses. In each interview, we ask how they got started, how they grew, and how they run their business today.", + "twitter:creator": "@thepatwalls", + "twitter:image": "https://d1coqmn8qm80r4.cloudfront.net/production/images/6fd0cbcde3a17eb5", + "viewport": "width=device-width, initial-scale=1.0", + "csrf-param": "authenticity_token", + "csrf-token": "Y/r2blv1BSrDAQ+KLrVUMUqVgc5W0UQXdS5chzqCA3BGvpgvK4LKV6rZteu/Ef4ApzMHW5k04EXQvR4wmg4EQg==" + }, + "link": { + "stylesheet": "https://d1kpq1xlswihti.cloudfront.net/assets/non_essential-5983ca74615a995e158d7aefdbad7fb8f78ee5eb023bc4852a51b13135b36d7b.css", + "icon": "https://d1kpq1xlswihti.cloudfront.net/assets/starterstory_favicon-1d56fe8e0cb50101dd68673cc80986ee5e7b409621e7da39a5154ac25d36bfb2.ico", + "alternate": "https://www.starterstory.com/feed?format=rss" + } +} diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.html b/packages/head-metadata/src/__tests__/resources/e2e/youtube.html new file mode 100644 index 00000000..dc47c913 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/youtube.html @@ -0,0 +1,15804 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Jai Howitt - YouTube + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+ + + + + + +
+
+
+
+
+
+ About + Press + Copyright + Contact us + Creators + Advertise + Developers + Impressum + Cancel Memberships + Terms + Privacy + Policy &Safety + How YouTube works + Test new features + +
+ + + + + + + + + + + + + + + + + + diff --git a/packages/head-metadata/src/__tests__/resources/e2e/youtube.json b/packages/head-metadata/src/__tests__/resources/e2e/youtube.json new file mode 100644 index 00000000..ac96a766 --- /dev/null +++ b/packages/head-metadata/src/__tests__/resources/e2e/youtube.json @@ -0,0 +1,51 @@ +{ + "meta": { + "theme-color": "rgba(255, 255, 255, 0.98)", + "description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.Much love,Jaihttps://www.instagram.com/jai.journeys", + "keywords": "Just Jai howitt artofmondays art of mondays", + "og:title": "Jai Howitt", + "og:site_name": "YouTube", + "og:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "og:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", + "og:image:width": "900", + "og:image:height": "900", + "og:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", + "al:ios:app_store_id": "544007664", + "al:ios:app_name": "YouTube", + "al:ios:url": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "al:android:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", + "al:android:app_name": "YouTube", + "al:android:package": "com.google.android.youtube", + "al:web:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA?feature=applinks", + "og:type": "profile", + "og:video:tag": "mondays", + "fb:app_id": "87741124305", + "twitter:card": "summary", + "twitter:site": "@youtube", + "twitter:url": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:title": "Jai Howitt", + "twitter:description": "Building in public. Weekly raw episodes on Mondays & some other stuff sprinkled in.\n\nMuch love,\nJai\n\nhttps://www.instagram.com/jai.journeys\n", + "twitter:image": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj", + "twitter:app:name:iphone": "YouTube", + "twitter:app:id:iphone": "544007664", + "twitter:app:name:ipad": "YouTube", + "twitter:app:id:ipad": "544007664", + "twitter:app:url:iphone": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:app:url:ipad": "vnd.youtube://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "twitter:app:name:googleplay": "YouTube", + "twitter:app:id:googleplay": "com.google.android.youtube", + "twitter:app:url:googleplay": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA" + }, + "title": "Jai Howitt - YouTube", + "link": { + "shortcut icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon.ico", + "icon": "https://www.youtube.com/s/desktop/8df21d66/img/logos/favicon_144x144.png", + "preload": "https://www.youtube.com/s/_/ytmainappweb/_/js/k=ytmainappweb.kevlar_base.en_US.QVf4ASTs3oE.es5.O/d=0/br=1/rs=AGKMywFzBElaMTZqv0bMqJHrSdSpi4kx7A", + "stylesheet": "https://www.youtube.com/s/_/ytmainappweb/_/ss/k=ytmainappweb.kevlar_base.HrTonLT-ODE.L.B1.O/am=AAAECQ/d=0/br=1/rs=AGKMywEP101ZRKVcNYsT1M6YX5N_tcp9IA", + "search": "https://www.youtube.com/opensearch?locale=en_US", + "manifest": "/manifest.webmanifest", + "canonical": "https://www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "alternate": "ios-app://544007664/vnd.youtube/www.youtube.com/channel/UCgAZ8TVwkH_gUH_rVbrunaA", + "image_src": "https://yt3.googleusercontent.com/ytc/AIdro_n4YnbRzb7802She3I2Tq7lPWbXsDhHItRK8SX6ImO3wtg=s900-c-k-c0x00ffffff-no-rj" + } +} diff --git a/packages/head-metadata/src/extract-head-metadata.test.ts b/packages/head-metadata/src/extract-head-metadata.test.ts new file mode 100644 index 00000000..f339e781 --- /dev/null +++ b/packages/head-metadata/src/extract-head-metadata.test.ts @@ -0,0 +1,31 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { extractHeadMetadata } from './extract-head-metadata'; +import { linkExtractor, metaExtractor, titleExtractor } from './extractors'; + +describe('extractHeadMetadata', () => { + it('should extract metadata from all test files', async () => { + // Get all HTML files from the e2e directory + const testFiles = await readdir(join(__dirname, './__tests__/resources/e2e')); + const htmlFiles = testFiles.filter((file) => file.endsWith('.html')); + + // Test each HTML file + for (const htmlFile of htmlFiles) { + const filePath = join(__dirname, './__tests__/resources/e2e', htmlFile); + const html = await readFile(filePath, 'utf-8'); + + // Get corresponding JSON file + const jsonPath = filePath.replace('.html', '.json'); + const expectedResults = JSON.parse(await readFile(jsonPath, 'utf-8')); + + // Extract metadata and assert + const metadata = await extractHeadMetadata(html, { + meta: metaExtractor, + title: titleExtractor, + link: linkExtractor + }); + expect(metadata).toEqual(expectedResults); + } + }); +}); diff --git a/packages/head-metadata/src/extract-head-metadata.ts b/packages/head-metadata/src/extract-head-metadata.ts new file mode 100644 index 00000000..02ea69a8 --- /dev/null +++ b/packages/head-metadata/src/extract-head-metadata.ts @@ -0,0 +1,115 @@ +import { htmlConfig, select, TXmlNode } from 'xml-tokenizer'; +import { TExtractCollectionKeys, TExtractors, TExtractSingleKeys } from './types'; + +export function extractHeadMetadata( + html: string, + extractors: GExtractors +): TExtractMetadata { + const metadata: TExtractMetadata = Object.keys(extractors).reduce((acc, key) => { + // @ts-expect-error -- We know that the key is a valid key since it comes from the extractors object + acc[key] = {}; + return acc; + }, {} as TExtractMetadata); + const stack: TXmlNode[] = []; + + select( + html, + [[{ axis: 'self-or-descendant', local: 'head' }]], + (token, stream) => { + switch (token.type) { + // Since HTML only has one head element, we can just go to the end of the file + // after we've processed the first head element + case 'SelectionEnd': { + stream.goToEnd(); + break; + } + case 'ElementStart': { + if (token.local in extractors) { + const newNode: TXmlNode = { + local: token.local, + prefix: token.prefix.length > 0 ? token.prefix : undefined, + attributes: [], + content: [] + }; + + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + currentNode.content.push(newNode); + } + + stack.push(newNode); + } + break; + } + case 'ElementEnd': { + if (token.end.type === 'Close' || token.end.type === 'Empty') { + const node = stack[stack.length - 1]; + if (node == null) { + break; + } + + const extractor = extractors[node.local]; + if (extractor != null) { + switch (extractor.type) { + case 'collection': { + const result = extractor.callback(node); + if (result != null) { + (metadata as any)[extractor.parent][result.key] = result.value; + } + break; + } + case 'single': { + const value = extractor.callback(node); + if (value != null) { + (metadata as any)[extractor.key] = value; + } + break; + } + } + } + + stack.pop(); + } + break; + } + case 'Attribute': { + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + currentNode.attributes.push({ + local: token.local, + prefix: token.prefix.length > 0 ? token.prefix : undefined, + value: token.value + }); + } + break; + } + case 'Text': + case 'Cdata': { + const currentNode = stack[stack.length - 1]; + if (currentNode != null) { + const trimmedText = token.text.trim(); + if (trimmedText.length > 0) { + currentNode.content.push(token.text); + } + } + break; + } + case 'Comment': + case 'ProcessingInstruction': + case 'EntityDeclaration': + case 'SelectionStart': + } + }, + htmlConfig + ); + + return metadata; +} + +export type TExtractMetadata = { + // Single value fields + [K in TExtractSingleKeys]: string; +} & { + // Collection fields + [K in TExtractCollectionKeys]: Record; +}; diff --git a/packages/head-metadata/src/extractors/index.ts b/packages/head-metadata/src/extractors/index.ts new file mode 100644 index 00000000..7961638a --- /dev/null +++ b/packages/head-metadata/src/extractors/index.ts @@ -0,0 +1,3 @@ +export * from './link-extractor'; +export * from './meta-extractor'; +export * from './title-extractor'; diff --git a/packages/head-metadata/src/extractors/link-extractor.ts b/packages/head-metadata/src/extractors/link-extractor.ts new file mode 100644 index 00000000..c8674ef9 --- /dev/null +++ b/packages/head-metadata/src/extractors/link-extractor.ts @@ -0,0 +1,15 @@ +import { TCollectionExtractor } from '../types'; + +export const linkExtractor = { + type: 'collection' as const, + parent: 'link' as const, + callback: (node) => { + const rel = node.attributes.find((a) => a.local === 'rel'); + const href = node.attributes.find((a) => a.local === 'href'); + if (rel != null && href != null) { + return { key: rel.value, value: href.value }; + } + + return null; + } +} satisfies TCollectionExtractor; diff --git a/packages/head-metadata/src/extractors/meta-extractor.ts b/packages/head-metadata/src/extractors/meta-extractor.ts new file mode 100644 index 00000000..78515906 --- /dev/null +++ b/packages/head-metadata/src/extractors/meta-extractor.ts @@ -0,0 +1,20 @@ +import { TCollectionExtractor } from '../types'; + +export const metaExtractor = { + type: 'collection' as const, + parent: 'meta' as const, + callback: (node) => { + const charset = node.attributes.find((a) => a.local === 'charset'); + if (charset != null) { + return { key: 'charset', value: charset.value }; + } + + const name = node.attributes.find((a) => a.local === 'name' || a.local === 'property'); + const content = node.attributes.find((a) => a.local === 'content'); + if (name != null && content != null) { + return { key: name.value, value: content.value }; + } + + return null; + } +} satisfies TCollectionExtractor; diff --git a/packages/head-metadata/src/extractors/title-extractor.ts b/packages/head-metadata/src/extractors/title-extractor.ts new file mode 100644 index 00000000..31876088 --- /dev/null +++ b/packages/head-metadata/src/extractors/title-extractor.ts @@ -0,0 +1,10 @@ +import { TSingleExtractor } from '../types'; + +export const titleExtractor = { + type: 'single' as const, + key: 'title' as const, + callback: (node) => { + const text = node.content[0]; + return typeof text === 'string' ? text.trim() : null; + } +} satisfies TSingleExtractor; diff --git a/packages/head-metadata/src/index.ts b/packages/head-metadata/src/index.ts new file mode 100644 index 00000000..671c6847 --- /dev/null +++ b/packages/head-metadata/src/index.ts @@ -0,0 +1,3 @@ +export * from './extract-head-metadata'; +export * from './extractors'; +export * from './types'; diff --git a/packages/head-metadata/src/types.ts b/packages/head-metadata/src/types.ts new file mode 100644 index 00000000..ead54603 --- /dev/null +++ b/packages/head-metadata/src/types.ts @@ -0,0 +1,34 @@ +export interface TXmlNode { + local: string; + prefix?: string; + attributes: { local: string; prefix?: string; value: string }[]; + content: (TXmlNode | string)[]; +} + +export type TCollectionExtractor = { + type: 'collection'; + parent: string; + callback: (node: TXmlNode) => { key: string; value: string } | null; +}; + +export type TSingleExtractor = { + type: 'single'; + key: string; + callback: (node: TXmlNode) => string | null; +}; + +export type TExtractor = TCollectionExtractor | TSingleExtractor; + +export type TExtractors = { + [K: string]: TExtractor; +}; + +export type TExtractCollectionKeys = { + [K in keyof GExtractors]: GExtractors[K] extends TCollectionExtractor + ? GExtractors[K]['parent'] + : never; +}[keyof GExtractors]; + +export type TExtractSingleKeys = { + [K in keyof GExtractors]: GExtractors[K] extends TSingleExtractor ? GExtractors[K]['key'] : never; +}[keyof GExtractors]; diff --git a/packages/head-metadata/tsconfig.json b/packages/head-metadata/tsconfig.json new file mode 100644 index 00000000..bf70a3c1 --- /dev/null +++ b/packages/head-metadata/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@blgc/config/typescript/library", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "declarationDir": "./dist/types" + }, + "include": ["src"], + "exclude": ["**/__tests__/*", "**/*.test.ts"] +} diff --git a/packages/head-metadata/tsconfig.prod.json b/packages/head-metadata/tsconfig.prod.json new file mode 100644 index 00000000..01151c39 --- /dev/null +++ b/packages/head-metadata/tsconfig.prod.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declarationMap": false + } +} diff --git a/packages/head-metadata/vitest.config.mjs b/packages/head-metadata/vitest.config.mjs new file mode 100644 index 00000000..8482b939 --- /dev/null +++ b/packages/head-metadata/vitest.config.mjs @@ -0,0 +1,4 @@ +import { nodeConfig } from '@blgc/config/vite/node'; +import { defineConfig, mergeConfig } from 'vitest/config'; + +export default mergeConfig(nodeConfig, defineConfig({})); diff --git a/packages/openapi-ts-router/package.json b/packages/openapi-ts-router/package.json index d2042590..22646611 100644 --- a/packages/openapi-ts-router/package.json +++ b/packages/openapi-ts-router/package.json @@ -42,9 +42,9 @@ "@blgc/config": "workspace:*", "@types/express": "^5.0.2", "@types/express-serve-static-core": "^5.0.6", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "express": "^5.1.0", - "hono": "^4.7.10", + "hono": "^4.7.11", "rollup-presets": "workspace:*", "valibot": "1.1.0", "validation-adapters": "workspace:*" diff --git a/packages/rollup-presets/package.json b/packages/rollup-presets/package.json index f5ce4e18..9ff50492 100644 --- a/packages/rollup-presets/package.json +++ b/packages/rollup-presets/package.json @@ -33,7 +33,7 @@ }, "dependencies": { "@rollup/plugin-commonjs": "^28.0.3", - "execa": "9.5.3", + "execa": "9.6.0", "picocolors": "^1.1.1", "rollup-plugin-dts": "^6.2.1", "rollup-plugin-esbuild": "^6.2.1", @@ -41,7 +41,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup": "^4.41.1", "type-fest": "^4.41.0" } diff --git a/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md b/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md index 8213fbb0..1cbc16da 100644 --- a/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md +++ b/packages/rollup-presets/src/plugins/rollup-plugin-ts-declarations/README.md @@ -4,4 +4,4 @@ https://github.com/Microsoft/TypeScript/wiki/Using-the-Compiler-API ## 🌟 Credits -- [rollup-plugin-dts](https://github.com/Swatinem/rollup-plugin-dts) \ No newline at end of file +- [rollup-plugin-dts](https://github.com/Swatinem/rollup-plugin-dts) diff --git a/packages/rollup-presets/tsconfig.json b/packages/rollup-presets/tsconfig.json index c4cc81bc..4cf69469 100644 --- a/packages/rollup-presets/tsconfig.json +++ b/packages/rollup-presets/tsconfig.json @@ -6,4 +6,4 @@ }, "include": ["src"], "exclude": ["**/__tests__/*", "**/*.test.ts"] -} \ No newline at end of file +} diff --git a/packages/utils/package.json b/packages/utils/package.json index 9b166504..64b48e3a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -36,7 +36,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validatenv/package.json b/packages/validatenv/package.json index 0bf8dcd7..23d19e95 100644 --- a/packages/validatenv/package.json +++ b/packages/validatenv/package.json @@ -40,7 +40,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validation-adapter/package.json b/packages/validation-adapter/package.json index b3ce5388..0c8d4014 100644 --- a/packages/validation-adapter/package.json +++ b/packages/validation-adapter/package.json @@ -39,7 +39,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*" }, "size-limit": [ diff --git a/packages/validation-adapters/package.json b/packages/validation-adapters/package.json index 5fba0076..4925e7b9 100644 --- a/packages/validation-adapters/package.json +++ b/packages/validation-adapters/package.json @@ -78,11 +78,11 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "rollup-presets": "workspace:*", "valibot": "1.1.0", "yup": "^1.6.1", - "zod": "^3.25.28" + "zod": "^3.25.46" }, "size-limit": [ { diff --git a/packages/xml-tokenizer/package.json b/packages/xml-tokenizer/package.json index 5ef9222e..4b051f77 100644 --- a/packages/xml-tokenizer/package.json +++ b/packages/xml-tokenizer/package.json @@ -37,7 +37,7 @@ }, "devDependencies": { "@blgc/config": "workspace:*", - "@types/node": "^22.15.21", + "@types/node": "^22.15.29", "@types/sax": "^1.2.7", "@types/xml2js": "^0.4.14", "camaro": "^6.2.3", diff --git a/packages/xml-tokenizer/src/__tests__/playground.test.ts b/packages/xml-tokenizer/src/__tests__/playground.test.ts index 5406ae0b..20263a58 100644 --- a/packages/xml-tokenizer/src/__tests__/playground.test.ts +++ b/packages/xml-tokenizer/src/__tests__/playground.test.ts @@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'; import { describe } from 'node:test'; import * as camaro from 'camaro'; import { beforeAll, expect, it } from 'vitest'; +import { htmlConfig } from '../config'; import { select } from '../selector'; import { tokenToXml } from '../token-to-xml'; import { xmlToSimplifiedObject } from '../xml-to-simplified-object'; @@ -15,15 +16,11 @@ describe('playground', () => { let html = ''; beforeAll(async () => { - html = await readFile(`${__dirname}/resources/google.html`, 'utf-8'); + html = await readFile(`${__dirname}/resources/kleinanzeigen.html`, 'utf-8'); }); it('[xml-tokenizer] shoud work', async () => { - const result = await xmlToSimplifiedObject(html, { - allowDtd: true, - rawTextElements: ['script', 'style'], - strictDocument: false - }); + const result = await xmlToSimplifiedObject(html, htmlConfig); console.log(result); }); diff --git a/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html b/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html new file mode 100644 index 00000000..7f972bd1 --- /dev/null +++ b/packages/xml-tokenizer/src/__tests__/resources/kleinanzeigen.html @@ -0,0 +1,7694 @@ + + + + + Laptop kleinanzeigen.de + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + +
+ + +
+
+ +

1 - 25 von 122.854 Ergebnissen für „laptop“ in Deutschland +

+
+ +
+ + + + Sortieren nach: + + + + + + + + + + + +
+
Neueste
+ +
    + + + + + + + +
  • + Neueste +
  • + + + + + + + +
  • + Niedrigster Preis +
  • + + + + + + + +
  • + Höchster Preis +
  • + +
+
+
+
+ +
+
+
+ + + + + + + + + + +
+ + + + +
+ +
+
+

Kategorien

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Elektronik + + + +  (94.671) + + + + + + +
      + +
    • + + + + + + + + + + + + + + + + + + + Notebooks + + + +  (79.558) + + + + + +
    • + +
    • + + + + + + + + + + + + + + + + + + + PC-Zubehör & Software + + + +  (12.095) + + + + + +
    • + + +
    • mehr
    • + +
    + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mode & Beauty + + + +  (20.255) + + + + + + + + +
  • + + +
  • Alle Kategorien
  • + +
+
+
+
+
+

Zustand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Neu + + + +  (6.360) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sehr Gut + + + +  (36.934) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gut + + + +  (18.974) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + In Ordnung + + + +  (4.444) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Defekt + + + +  (3.743) + + + + + +
  • + + +
+
+
+
+
+

Versand in Notebooks

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (63.704) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (15.468) + + + + + +
  • + + +
+
+
+
+
+

Preis

+
+
+
+
+ -
+ + +
+
+
+
+
+

Angebotstyp

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Angebote + + + +  (122.026) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gesuche + + + +  (828) + + + + + +
  • + + +
+
+
+
+
+

Anbieter

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Privat + + + +  (116.648) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Gewerblich + + + +  (6.206) + + + + + +
  • + + +
+
+
+
+
+

Direkt kaufen

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Aktiv + + + +  (39.289) + + + + + +
  • + + +
+
+
+
+
+

Versand

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Versand möglich + + + +  (98.057) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nur Abholung + + + +  (23.412) + + + + + +
  • + + +
+
+
+
+
+

Paketdienst

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + DHL + + + +  (77.046) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hermes + + + +  (68.437) + + + + + +
  • + + +
+
+
+
+
+

Ort

+
+
+ + + + + + + + + + + + + +
    + +
  • + + + + + + + + + + + + + + + + + + Baden-Württemberg + + + +  (16.172) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bayern + + + +  (21.133) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Berlin + + + +  (7.923) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Brandenburg + + + +  (2.567) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Bremen + + + +  (1.098) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hamburg + + + +  (4.041) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Hessen + + + +  (9.930) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Mecklenburg-Vorpommern + + + +  (1.353) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Niedersachsen + + + +  (11.485) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Nordrhein-Westfalen + + + +  (26.815) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Rheinland-Pfalz + + + +  (5.254) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Saarland + + + +  (1.199) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen + + + +  (5.159) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Sachsen-Anhalt + + + +  (1.905) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Schleswig-Holstein + + + +  (4.789) + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + Thüringen + + + +  (2.031) + + + + + +
  • + + +
+
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 13” M2 (2022) – 16GB / 512GB – Neu & OVP -US-Tastatur Berlin - Charlottenburg Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10585 Charlottenburg + + + +
    +
    + +
    +
    +
    +

    + + + + MacBook Pro 13” M2 (2022) – 16GB / 512GB – Neu & OVP -US-Tastatur + + +

    +

    Ich verkaufe ein originalverpacktes, unbenutztes Apple MacBook Pro 13 Zoll mit M2 Chip (Modell...

    + +
    +

    + 1.099 € + +

    1.300 €

    + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo ThinkPad T470 acculess i5-6300U/2,4GHz/16GB/256GB Win10pro Brandenburg - Cottbus Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 03044 Cottbus + + + +
    +
    + +
    +
    +
    +

    + + + + Lenovo ThinkPad T470 acculess i5-6300U/2,4GHz/16GB/256GB Win10pro + + +

    +

    Das ThinkPad T470 mit Intel Core i5-6300U 2.40 GHz, 16GB RAM und 256GB NVMe von Lenovo ist ein...

    + +
    +

    + 99 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + + + + + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Fujits Lifebook U758 defekt Nordrhein-Westfalen - Langenfeld Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 40764 Langenfeld + + + +
    +
    + + + Heute, 08:24 + +
    +
    +
    +

    + + + + Fujits Lifebook U758 defekt + + +

    +

    Zum Verkauf steht ein defekter Fujits Lifebook U758 +Das Teil hat irgendwann aufgehört was zu...

    + +
    +

    + 15 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro A1502 16 GB Aachen - Aachen-Mitte Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 52066 Aachen-​Mitte + + + +
    +
    + + + Heute, 08:24 + +
    +
    +
    +

    + + + + MacBook Pro A1502 16 GB + + +

    +

    16GB RAM // Intel i5 // 128 GB Flashspeicher // 13“ + +Das MacBook...

    + +
    +

    + 350 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD Wandsbek - Hamburg Rahlstedt Vorschau + + + +
    + 14 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 22147 Hamburg Rahlstedt + + + +
    +
    + + + Heute, 08:23 + +
    +
    +
    +

    + + + + Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD + + +

    +

    Lenovo Thinkpad T480s i7-8550u 16 GB RAM 512 GB SSD M.2 SSD (Display IPS) + + +⚙️ BESONDERHEITEN ⚙️ + +►...

    + +
    +

    + 360 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer Chromebook Sachsen - Hoyerswerda Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 02977 Hoyerswerda + + + +
    +
    + + + Heute, 08:22 + +
    +
    +
    +

    + + + + Acer Chromebook + + +

    +

    Laptop von Acer+ Bloototh Maus +sehr guter Zustand + +Bezahlung bei Abholung oder per PayPal möglich

    + +
    +

    + 200 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • + +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop Tasche von George Gina Lucy Brandenburg - Großbeeren Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 14979 Großbeeren + + + +
    +
    + + + Heute, 08:18 + +
    +
    +
    +

    + + + + Laptop Tasche von George Gina Lucy + + +

    +

    Laptop Tasche zu verkaufen. Kann auch versandt werden. Etwas eingestaubt Aber Käufer trägt die...

    + +
    +

    + 10 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Vaude:Collegetasche, Laptoptasche, Umhängetasche Nordrhein-Westfalen - Siegburg Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 53721 Siegburg + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Vaude:Collegetasche, Laptoptasche, Umhängetasche + + +

    +

    Maße: 35 x 30 cm. + +Ideal für die Schule oder die Uni. Guter, benutzter Zustand. Keine Löcher oder...

    + +
    +

    + 25 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Latitude 7320 Detachable Kr. München - Planegg Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 82152 Planegg + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Latitude 7320 Detachable + + +

    +

    Ich verkaufe mein zuverlässiges Notebook&Tablett da ich kürzlich auf ein neues Gerät umgestiegen...

    + +
    +

    + 450 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Air 13,3 4BG RAM 251GB SSD Niedersachsen - Bötersen Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 27367 Bötersen + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + Apple MacBook Air 13,3 4BG RAM 251GB SSD + + +

    +

    Abzugeben ist ein gebrauchtes Apple MacBook Air 13,3 Zoll inkl. Netzteil. Das Book wurden...

    + +
    +

    + 149 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + macbook , 13", 1466, emc 2632, mitte 2013, qwerty Rheinland-Pfalz - Gau-Algesheim Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 55435 Gau-​Algesheim + + + +
    +
    + + + Heute, 08:16 + +
    +
    +
    +

    + + + + macbook , 13", 1466, emc 2632, mitte 2013, qwerty + + +

    +

    macbook , 13", 1466, emc 2632, mitte 2013, qwerty, +ich habe zur probe ventura installiert und...

    + +
    +

    + 65 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer aspire 5 Baden-Württemberg - Illingen Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 75428 Illingen + + + +
    +
    + + + Heute, 08:15 + +
    +
    +
    +

    + + + + Acer aspire 5 + + +

    +

    Fast neu , Schreiben Sie alle Fragen in die Nachricht

    + +
    +

    + 1 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Apple MacBook Air 13,6" 2025 M4/16GB/256GB SSD Silver Baden-Württemberg - Heidelberg Vorschau + + + +
    + 6 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 69115 Heidelberg + + + +
    +
    + + + Heute, 08:14 + +
    +
    +
    +

    + + + + Apple MacBook Air 13,6" 2025 M4/16GB/256GB SSD Silver + + +

    +

    Schönen guten Tag, + +Verkaufe hier mein MacBook, da ich es geschäftlich überraschend nicht mehr...

    + +
    +

    + 800 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MSI Katana 17,3 Zoll, Core i7 12700H,16 GB, 512 TB SSD,RTX 3060ti Nordrhein-Westfalen - Menden Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58706 Menden + + + +
    +
    + + + Heute, 08:13 + +
    +
    +
    +

    + + + + MSI Katana 17,3 Zoll, Core i7 12700H,16 GB, 512 TB SSD,RTX 3060ti + + +

    +

    CPU: Core i7-12700H + +RAM: 16GB DDR4/3200MHz + +GPU: Nvidia GeForce RTX 3060Ti + +Display:...

    + +
    +

    + 550 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Lenovo Thinkpad T500 Laptop Niedersachsen - Reppenstedt Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 21391 Reppenstedt + + + +
    +
    + + + Heute, 08:12 + +
    +
    +
    +

    + + + + Lenovo Thinkpad T500 Laptop + + +

    +

    Ich verkaufe hier einen zuverlässigen Lenovo ThinkPad T500 + +Technische Daten: + • Prozessor: Intel...

    + +
    +

    + 222 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook ASUS R512M, N1312 Baden-Württemberg - Karlsruhe Vorschau + + + +
    + 4 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 76185 Karlsruhe + + + +
    +
    + + + Heute, 08:12 + +
    +
    +
    +

    + + + + Notebook ASUS R512M, N1312 + + +

    +

    Notebook ASUS R512M, N1312 in weiß mit Laufwerk aber ohne Netzteil und Festplatte zu...

    + +
    +

    + 30 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Compaq Presario B1011 Vintage Retro Notebook Laptop Brandenburg - Potsdam Vorschau + + + +
    + 5 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 14480 Potsdam + + + +
    +
    + + + Heute, 08:11 + +
    +
    +
    +

    + + + + Compaq Presario B1011 Vintage Retro Notebook Laptop + + +

    +

    Compaq Presario B1011 Notebook Laptop. Das Notebook wurde nicht getestet und wird daher als...

    + +
    +

    + 20 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Asus Rog strix G18 (OVP u. Garantie) Thüringen - Erfurt Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 99086 Erfurt + + + +
    +
    + + + Heute, 08:11 + +
    +
    +
    +

    + + + + Asus Rog strix G18 (OVP u. Garantie) + + +

    +

    Hallo der Laptop ist knapp 1 Jahr alt und hat mir immer Spaß gemacht doch möchte ich ihn jetzt...

    + +
    +

    + 1.600 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
  • + +
    + +
  • + + + + + + + + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Pro 15 Zoll, Mitte 2010 Bayern - Erlangen Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 91058 Erlangen + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + MacBook Pro 15 Zoll, Mitte 2010 + + +

    +

    MacBook Pro 15 Zoll, Mitte 2010 +CPU: Intel i5 2,53 GHz +RAM: 8 Gb +SSD: 250 Gb +Grafikkarte: NVidia...

    + +
    +

    + 100 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Razer Blade 18(2024)QHD+ Core i9-14900HX 32GBRAM 1TB SSD RTX 4080 Feldmoching-Hasenbergl - Feldmoching Vorschau + + + +
    + 9 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 80995 Feldmoching + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Razer Blade 18(2024)QHD+ Core i9-14900HX 32GBRAM 1TB SSD RTX 4080 + + +

    +

    Hallo zusammen, + +ich verkaufe privat diesen Laptop für einen Freund. + +- Nichtraucherhaushalt +- nur...

    + +
    +

    + 3.400 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop omen gamer Lübeck - Buntekuh Vorschau + + + +
    + 7 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 23556 Buntekuh + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Laptop omen gamer + + +

    +

    Leistung siehe Bilder +top Zustand auch der Akku +13 Monate alt +neu Preis 1500 euro

    + +
    +

    + 750 € + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Acer Swift 3 Ryzen 5 Prozessor 8 GB RAM 500 GB SSD Pankow - Prenzlauer Berg Vorschau + + + +
    + 13 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 10249 Prenzlauer Berg + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + Acer Swift 3 Ryzen 5 Prozessor 8 GB RAM 500 GB SSD + + +

    +

    Acer Swift 3 +Ryzen 5 Prozessor +8 GB RAM +Windows 10 +Netzteil +Original Verpackung +Akku hält ca 5...

    + +
    +

    + 170 € + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + MacBook Air 2018 – 13" Retina – 8 GB RAM Rheinland-Pfalz - Mainz Vorschau + + + +
    + 3 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 55131 Mainz + + + +
    +
    + + + Heute, 08:10 + +
    +
    +
    +

    + + + + MacBook Air 2018 – 13" Retina – 8 GB RAM + + +

    +

    Ich verkaufe mein MacBook Air (Modell 2018) mit 13-Zoll-Retina-Display und 8 GB RAM. Das Gerät ist...

    + +
    +

    + 250 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Notebook 17 " Dithmarschen - Heide Vorschau + + + +
    + 8 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 25746 Heide + + + +
    +
    + + + Heute, 08:09 + +
    +
    +
    +

    + + + + Notebook 17 " + + +

    +

    Verkaufe ein 17 " Notebook. +Techn.Daten folgen. +Neupreis war 600 € +Mit vielen Programmen +Die...

    + +
    +

    + 150 € VB + +

    +
    + +
    +
    +

    + + + + + + Direkt kaufen + + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Laptop 12gb ram Nordrhein-Westfalen - Hagen Vorschau + + + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 58097 Hagen + + + +
    +
    + + + Heute, 08:07 + +
    +
    +
    +

    + + + + Laptop 12gb ram + + +

    +

    Guten abend ich verkaufe mein laptop funktioniert einwandfrei ohne probleme + +Cpu: intel i5...

    + +
    +

    + 100 € VB + +

    +
    + +
    +
    +

    + + + + + Versand möglich + + +

    + +
    +
    +
    +
  • + + + + + + + + + + + + + + +
  • +
    +
    + + + + + + + + + +
    + Samsung Galaxy Book Go LTE Hessen - Habichtswald Vorschau + + + +
    + 2 +
    + + + + +
    +
    + + + + + +
    +
    +
    +
    + + 34317 Habichtswald + + + +
    +
    + + + Heute, 08:06 + +
    +
    +
    +

    + + + + Samsung Galaxy Book Go LTE + + +

    +

    Das Samsung Galaxy Book Go LTE (345XLA-KB3) ist ein leistungsstarkes und vielseitiges Notebook, das...

    + +
    +

    + 140 € + +

    +
    + +
    +
    +

    + + +

    + +
    +
    +
    +
  • + + + +
+ + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+ + + + + + + + + +
+

Ähnliche Suchanfragen

+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + + +
+ +
+
+ +
+ + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + diff --git a/packages/xml-tokenizer/src/index.ts b/packages/xml-tokenizer/src/index.ts index dddf3e44..ef5813e5 100644 --- a/packages/xml-tokenizer/src/index.ts +++ b/packages/xml-tokenizer/src/index.ts @@ -1,5 +1,6 @@ export * from './config'; export * from './get-q-name'; +export * from './processor'; export * from './selector'; export * from './token-to-xml'; export * from './tokenizer'; diff --git a/packages/xml-tokenizer/src/processor/index.ts b/packages/xml-tokenizer/src/processor/index.ts new file mode 100644 index 00000000..e650d636 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/index.ts @@ -0,0 +1,4 @@ +export * from './process'; +export * from './processors'; +export * from './types'; +export * from './validate-processor-order'; diff --git a/packages/xml-tokenizer/src/processor/process.test.ts b/packages/xml-tokenizer/src/processor/process.test.ts new file mode 100644 index 00000000..ca0130a7 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/process.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it } from 'vitest'; +import { htmlConfig } from '../config'; +import { process } from './process'; +import { TProcessor } from './types'; + +describe('process function', () => { + const simpleXml = 'text'; + + it('should handle empty processors', () => { + const result = process(simpleXml, [], htmlConfig); + expect(result).toEqual({}); + }); + + it('should process with single processor', () => { + const counter: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const result = process(simpleXml, [counter], htmlConfig); + expect(result.count).toBe(2); // root + item + }); + + it('should process with multiple processors', () => { + const elementCounter: TProcessor<{ elements: number }> = { + context: { elements: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.elements++; + } + } + }; + + const textCounter: TProcessor<{ texts: number }> = { + context: { texts: 0 }, + process: (token, context) => { + if (token.type === 'Text' && token.text.trim().length > 0) { + context.texts++; + } + } + }; + + const result = process(simpleXml, [elementCounter, textCounter], htmlConfig); + expect(result.elements).toBe(2); + expect(result.texts).toBe(1); + }); + + it('should merge contexts from all processors', () => { + const processor1: TProcessor<{ prop1: string }> = { + context: { prop1: 'value1' }, + process: () => {} + }; + + const processor2: TProcessor<{ prop2: string }> = { + context: { prop2: 'value2' }, + process: () => {} + }; + + const result = process(simpleXml, [processor1, processor2], htmlConfig); + expect(result.prop1).toBe('value1'); + expect(result.prop2).toBe('value2'); + }); + + it('should allow processors to share data', () => { + const shared: TProcessor<{ sharedValue: number }> = { + context: { sharedValue: 0 } + }; + + const setter: TProcessor<{ setValue: number }, [typeof shared]> = { + context: { setValue: 0 }, + deps: [shared], + process: (token, context) => { + if (token.type === 'ElementStart') { + context.sharedValue = 42; + context.setValue = 42; + } + } + }; + + const reader: TProcessor<{ getValue: number }, [typeof shared]> = { + context: { getValue: 0 }, + deps: [shared], + process: (token, context) => { + if (token.type === 'Text') { + context.getValue = context.sharedValue; + } + } + }; + + const result = process(simpleXml, [shared, setter, reader], htmlConfig); + expect(result.setValue).toBe(42); + expect(result.getValue).toBe(42); + }); + + it('should work with correct dependency order', () => { + const base: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const dependent: TProcessor<{ doubled: number }, [typeof base]> = { + context: { doubled: 0 }, + deps: [base], + process: (token, context) => { + if (token.type === 'ElementEnd') { + context.doubled = context.count * 2; + } + } + }; + + const result = process(simpleXml, [base, dependent], htmlConfig); + expect(result.count).toBe(2); + expect(result.doubled).toBe(4); + }); + + it('should throw error with wrong dependency order', () => { + const base: TProcessor<{ count: number }> = { + name: 'BaseCounter', + context: { count: 0 }, + process: () => {} + }; + + const dependent: TProcessor<{ items: string[] }, [typeof base]> = { + name: 'DependentProcessor', + context: { items: [] }, + deps: [base], + process: () => {} + }; + + expect(() => { + process(simpleXml, [dependent, base], htmlConfig); + }).toThrow('Processor dependency order invalid'); + }); + + it('should handle multiple dependencies', () => { + const counter: TProcessor<{ count: number }> = { + context: { count: 0 }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.count++; + } + } + }; + + const collector: TProcessor<{ names: string[] }> = { + context: { names: [] }, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.names.push(token.local); + } + } + }; + + const combiner: TProcessor<{ summary: string }, [typeof counter, typeof collector]> = { + context: { summary: '' }, + deps: [counter, collector], + process: (token, context) => { + if ( + token.type === 'ElementEnd' && + token.end.type === 'Close' && + token.end.local === 'root' + ) { + context.summary = `${context.count} elements: ${context.names.join(', ')}`; + } + } + }; + + const result = process(simpleXml, [counter, collector, combiner], htmlConfig); + expect(result.count).toBe(2); + expect(result.names).toEqual(['root', 'item']); + expect(result.summary).toBe('2 elements: root, item'); + }); + + it('should keep references in shared context', () => { + const sharedData = { list: ['start'] }; + + const writer: TProcessor = { + context: sharedData, + process: (token, context) => { + if (token.type === 'ElementStart') { + context.list.push('written'); + } + } + }; + + const reader: TProcessor<{ result: string }> = { + context: { result: '' }, + process: (token, context) => { + if (token.type === 'Text') { + context.result = (context as any).list.join('-'); + } + } + }; + + const result = process(simpleXml, [writer, reader], htmlConfig); + + // Both should reference the same array + expect(result.list).toBe(sharedData.list); + expect(result.result).toBe('start-written-written'); // reader saw writer's changes + }); +}); diff --git a/packages/xml-tokenizer/src/processor/process.ts b/packages/xml-tokenizer/src/processor/process.ts new file mode 100644 index 00000000..2c3167aa --- /dev/null +++ b/packages/xml-tokenizer/src/processor/process.ts @@ -0,0 +1,30 @@ +import { tokenize, type TXmlStreamOptions } from '../tokenizer'; +import { TMergeProcessorContexts, TProcessorAny } from './types'; +import { validateProcessorOrder } from './validate-processor-order'; + +export function process( + xml: string, + processors: [...GProcessors], + options?: TXmlStreamOptions +): TMergeProcessorContexts { + validateProcessorOrder(processors); + + // Build shared context + const context: TMergeProcessorContexts = {} as TMergeProcessorContexts; + for (const processor of processors) { + Object.assign(context, processor.context); + } + + // Process tokens + tokenize( + xml, + (token) => { + for (const processor of processors) { + processor.process?.(token, context); + } + }, + options + ); + + return context; +} diff --git a/packages/xml-tokenizer/src/processor/processors/index.ts b/packages/xml-tokenizer/src/processor/processors/index.ts new file mode 100644 index 00000000..5c6c6a47 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/index.ts @@ -0,0 +1 @@ +export { pathTracker, type TPathTrackerContext } from './path-tracker'; diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts new file mode 100644 index 00000000..266c5472 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { htmlConfig } from '../../config'; +import { process } from '../process'; +import { TProcessor } from '../types'; +import { pathTracker } from './path-tracker'; + +describe('pathTracker processor', () => { + const simpleXml = `

Title

`; + + it('should track path correctly', () => { + const result = process(simpleXml, [pathTracker], htmlConfig); + + // After processing, should be back to empty path + expect(result.currentPath).toEqual([]); + }); + + it('should work with dependent processor that uses path', () => { + // Processor that depends on pathTracker and collects paths + const pathCollector: TProcessor<{ visitedPaths: string[] }, [typeof pathTracker]> = { + name: 'PathCollector', + context: { visitedPaths: [] }, + deps: [pathTracker], + process: (token, context) => { + if (token.type === 'ElementStart' && context.currentPath) { + context.visitedPaths.push(context.currentPath.join('/')); + } + } + }; + + const result = process(simpleXml, [pathTracker, pathCollector], htmlConfig); + + expect(result.visitedPaths).toEqual(['article', 'article/header', 'article/header/h1']); + }); +}); diff --git a/packages/xml-tokenizer/src/processor/processors/path-tracker.ts b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts new file mode 100644 index 00000000..49dcc3f2 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/processors/path-tracker.ts @@ -0,0 +1,21 @@ +import type { TProcessor } from '../types'; + +export type TPathTrackerContext = { + currentPath: string[]; +}; + +export const pathTracker: TProcessor = { + name: 'PathTracker', + context: { + currentPath: [] + }, + process: (token, context) => { + if (token.type === 'ElementStart') { + // Add element to path + const elementName = token.prefix ? `${token.prefix}:${token.local}` : token.local; + context.currentPath.push(elementName); + } else if (token.type === 'ElementEnd' && token.end.type === 'Close') { + context.currentPath.pop(); + } + } +}; diff --git a/packages/xml-tokenizer/src/processor/types.ts b/packages/xml-tokenizer/src/processor/types.ts new file mode 100644 index 00000000..1ab37ae9 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/types.ts @@ -0,0 +1,24 @@ +import { TXmlToken } from '../tokenizer'; + +export interface TProcessor { + name?: string; + context: GContext; + deps?: GDeps; + process?: TProcessorTokenCallback>; +} + +export type TProcessorAny = TProcessor; + +export type TProcessorTokenCallback = ( + token: TXmlToken, + context: GContext +) => void; + +export type TMergeProcessorContexts = + GProcessors extends [infer Head, ...infer Tail] + ? Head extends TProcessorAny + ? Tail extends readonly TProcessorAny[] + ? Head['context'] & TMergeProcessorContexts + : Head['context'] + : {} + : {}; diff --git a/packages/xml-tokenizer/src/processor/validate-processor-order.ts b/packages/xml-tokenizer/src/processor/validate-processor-order.ts new file mode 100644 index 00000000..bf2b1483 --- /dev/null +++ b/packages/xml-tokenizer/src/processor/validate-processor-order.ts @@ -0,0 +1,46 @@ +import { TProcessorAny } from './types'; + +export function validateProcessorOrder(processors: TProcessorAny[]) { + const seen = new Set(); + + for (let i = 0; i < processors.length; i++) { + const processor = processors[i] as TProcessorAny; + + if (processor.deps == null) { + seen.add(processor); + continue; + } + + const missingDeps: TProcessorAny[] = []; + for (const dep of processor.deps) { + if (!seen.has(dep)) { + missingDeps.push(dep); + } + } + + if (missingDeps.length > 0) { + const processorName = processor.name || `Processor[${i}]`; + const missingNames = missingDeps + .map((dep, idx) => { + const depIndex = processors.indexOf(dep); + return dep.name || `Processor[${depIndex >= 0 ? depIndex : idx}]`; + }) + .join(', '); + + const currentOrder = processors + .filter((p) => p != null) + .map((p, idx) => p.name || `Processor[${idx}]`) + .join(' → '); + + throw new Error( + `Processor dependency order invalid:\n` + + ` ${processorName} depends on: ${missingNames}\n` + + ` But those dependencies haven't been processed yet.\n` + + ` Current order: ${currentOrder}\n` + + ` Solution: Move dependencies before ${processorName} in the processors array.` + ); + } + + seen.add(processor); + } +} diff --git a/packages/xml-tokenizer/src/tokenizer/tokenize.ts b/packages/xml-tokenizer/src/tokenizer/tokenize.ts index 9d777efc..f0a62679 100644 --- a/packages/xml-tokenizer/src/tokenizer/tokenize.ts +++ b/packages/xml-tokenizer/src/tokenizer/tokenize.ts @@ -641,7 +641,7 @@ function parseText( // According to the spec, `]]>` must not appear inside a Text node. // https://www.w3.org/TR/xml/#syntax - if (text.includes(CDATA_END)) { + if (s.config.strictDocument && text.includes(CDATA_END)) { throw new XmlError({ type: 'InvalidCharacterData' }, s.genTextPos()); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0e567850..3db9335a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,7 +19,7 @@ importers: version: 2.29.4 '@ianvs/prettier-plugin-sort-imports': specifier: ^4.4.1 - version: 4.4.1(prettier@3.5.3) + version: 4.4.2(prettier@3.5.3) '@size-limit/esbuild': specifier: ^11.2.0 version: 11.2.0(size-limit@11.2.0) @@ -31,13 +31,13 @@ importers: version: 11.2.0(size-limit@11.2.0) eslint: specifier: ^9.27.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) prettier: specifier: ^3.5.3 version: 3.5.3 prettier-plugin-tailwindcss: specifier: ^0.6.11 - version: 0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3) + version: 0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3) rollup: specifier: ^4.41.1 version: 4.41.1 @@ -49,13 +49,26 @@ importers: version: 11.2.0 turbo: specifier: ^2.5.3 - version: 2.5.3 + version: 2.5.4 typescript: specifier: ^5.8.3 version: 5.8.3 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4) + version: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4) + + examples/feature-ecs/vanilla/basic: + dependencies: + feature-ecs: + specifier: workspace:* + version: link:../../../../packages/feature-ecs + devDependencies: + typescript: + specifier: ~5.8.3 + version: 5.8.3 + vite: + specifier: ^6.3.5 + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/feature-fetch/vanilla/open-meteo: dependencies: @@ -71,7 +84,7 @@ importers: version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/feature-form/react/basic: dependencies: @@ -92,44 +105,44 @@ importers: version: 19.1.0(react@19.1.0) valibot: specifier: 1.0.0-beta.9 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.9(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters zod: specifier: ^3.24.1 - version: 3.25.28 + version: 3.25.46 devDependencies: '@types/react': specifier: ^19.0.2 - version: 19.1.5 + version: 19.1.6 '@types/react-dom': specifier: ^19.0.2 - version: 19.1.5(@types/react@19.1.5) + version: 19.1.5(@types/react@19.1.6) '@typescript-eslint/eslint-plugin': specifier: ^8.18.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.18.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) eslint: specifier: ^9.17.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) eslint-plugin-react-hooks: specifier: ^5.1.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-refresh: specifier: ^0.4.16 - version: 0.4.20(eslint@9.27.0(jiti@2.4.2)) + version: 0.4.20(eslint@9.28.0(jiti@2.4.2)) typescript: specifier: ^5.7.2 version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/feature-state/react/counter: dependencies: @@ -151,46 +164,46 @@ importers: devDependencies: '@types/react': specifier: ^19.0.2 - version: 19.1.5 + version: 19.1.6 '@types/react-dom': specifier: ^19.0.2 - version: 19.1.5(@types/react@19.1.5) + version: 19.1.5(@types/react@19.1.6) '@typescript-eslint/eslint-plugin': specifier: ^8.18.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': specifier: ^8.18.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@vitejs/plugin-react': specifier: ^4.3.4 - version: 4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) eslint: specifier: ^9.17.0 - version: 9.27.0(jiti@2.4.2) + version: 9.28.0(jiti@2.4.2) eslint-plugin-react-hooks: specifier: ^5.1.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-refresh: specifier: ^0.4.16 - version: 0.4.20(eslint@9.27.0(jiti@2.4.2)) + version: 0.4.20(eslint@9.28.0(jiti@2.4.2)) typescript: specifier: ^5.7.2 version: 5.8.3 vite: specifier: ^6.0.5 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) examples/openapi-ts-router/express/petstore: dependencies: express: specifier: ^4.21.2 - version: 5.1.0 + version: 4.21.2 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router valibot: specifier: 1.0.0-beta.12 - version: 1.1.0(typescript@5.8.3) + version: 1.0.0-beta.12(typescript@5.8.3) validation-adapters: specifier: workspace:* version: link:../../../../packages/validation-adapters @@ -203,7 +216,7 @@ importers: version: 5.0.2 '@types/node': specifier: ^22.10.7 - version: 22.15.21 + version: 22.15.29 nodemon: specifier: ^3.1.9 version: 3.1.10 @@ -212,7 +225,7 @@ importers: version: 7.8.0(typescript@5.8.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@types/node@22.15.21)(typescript@5.8.3) + version: 10.9.2(@types/node@22.15.29)(typescript@5.8.3) tsconfig-paths: specifier: ^4.2.0 version: 4.2.0 @@ -221,13 +234,13 @@ importers: dependencies: '@hono/node-server': specifier: ^1.13.7 - version: 1.14.2(hono@4.7.10) + version: 1.14.3(hono@4.7.11) '@hono/zod-validator': specifier: ^0.4.2 - version: 0.5.0(hono@4.7.10)(zod@3.25.28) + version: 0.4.3(hono@4.7.11)(zod@3.25.46) hono: specifier: ^4.6.15 - version: 4.7.10 + version: 4.7.11 openapi-ts-router: specifier: workspace:* version: link:../../../../packages/openapi-ts-router @@ -236,11 +249,11 @@ importers: version: link:../../../../packages/validation-adapters zod: specifier: ^3.24.1 - version: 3.25.28 + version: 3.25.46 devDependencies: '@types/node': specifier: ^22.10.3 - version: 22.15.21 + version: 22.15.29 tsx: specifier: ^4.19.2 version: 4.19.4 @@ -252,10 +265,10 @@ importers: version: 6.2.3 fast-xml-parser: specifier: ^4.4.1 - version: 5.2.3 + version: 4.5.3 tinybench: specifier: ^2.9.0 - version: 4.0.1 + version: 2.9.0 txml: specifier: ^5.1.1 version: 5.1.1 @@ -274,56 +287,56 @@ importers: version: 5.8.3 vite: specifier: ^5.3.4 - version: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + version: 5.4.19(@types/node@22.15.29) packages/config: dependencies: '@ianvs/prettier-plugin-sort-imports': - specifier: ^4.4.1 - version: 4.4.1(prettier@3.5.3) + specifier: ^4.4.2 + version: 4.4.2(prettier@3.5.3) '@next/eslint-plugin-next': - specifier: ^15.3.2 - version: 15.3.2 + specifier: ^15.3.3 + version: 15.3.3 '@typescript-eslint/eslint-plugin': - specifier: ^8.32.1 - version: 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + specifier: ^8.33.0 + version: 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) '@typescript-eslint/parser': - specifier: ^8.32.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + specifier: ^8.33.0 + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) eslint-config-prettier: specifier: ^10.1.5 - version: 10.1.5(eslint@9.27.0(jiti@2.4.2)) + version: 10.1.5(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-only-warn: specifier: ^1.1.0 version: 1.1.0 eslint-plugin-react: specifier: ^7.37.5 - version: 7.37.5(eslint@9.27.0(jiti@2.4.2)) + version: 7.37.5(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-react-hooks: specifier: ^5.2.0 - version: 5.2.0(eslint@9.27.0(jiti@2.4.2)) + version: 5.2.0(eslint@9.28.0(jiti@2.4.2)) eslint-plugin-turbo: - specifier: ^2.5.3 - version: 2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.3) + specifier: ^2.5.4 + version: 2.5.4(eslint@9.28.0(jiti@2.4.2))(turbo@2.5.4) prettier-plugin-css-order: specifier: ^2.1.2 - version: 2.1.2(postcss@8.5.3)(prettier@3.5.3) + version: 2.1.2(postcss@8.5.4)(prettier@3.5.3) prettier-plugin-packagejson: - specifier: ^2.5.14 - version: 2.5.14(prettier@3.5.3) + specifier: ^2.5.15 + version: 2.5.15(prettier@3.5.3) prettier-plugin-tailwindcss: - specifier: ^0.6.11 - version: 0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3) + specifier: ^0.6.12 + version: 0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3) typescript-eslint: - specifier: ^8.32.1 - version: 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + specifier: ^8.33.0 + version: 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) vite-tsconfig-paths: specifier: ^5.1.4 - version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + version: 5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) devDependencies: eslint: - specifier: ^9.27.0 - version: 9.27.0(jiti@2.4.2) + specifier: ^9.28.0 + version: 9.28.0(jiti@2.4.2) prettier: specifier: ^3.5.3 version: 3.5.3 @@ -332,7 +345,7 @@ importers: version: 5.8.3 vitest: specifier: ^3.1.4 - version: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4) + version: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4) packages/elevenlabs-client: dependencies: @@ -350,8 +363,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -378,8 +391,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -390,11 +403,30 @@ importers: specifier: workspace:* version: link:../rollup-presets + packages/feature-ecs: + dependencies: + '@blgc/utils': + specifier: ^0.0.52 + version: 0.0.52 + devDependencies: + '@blgc/config': + specifier: workspace:* + version: link:../config + '@types/node': + specifier: ^22.15.29 + version: 22.15.29 + bitecs: + specifier: github:NateTheGreatt/bitECS#rc-0-4-0 + version: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f + rollup-presets: + specifier: workspace:* + version: link:../rollup-presets + packages/feature-fetch: dependencies: '@0no-co/graphql.web': specifier: ^1.1.2 - version: 1.1.2(graphql@16.10.0) + version: 1.1.2(graphql@16.11.0) '@blgc/types': specifier: workspace:* version: link:../types @@ -406,14 +438,14 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 '@types/url-parse': specifier: ^1.4.11 version: 1.4.11 msw: - specifier: ^2.8.4 - version: 2.8.4(@types/node@22.15.21)(typescript@5.8.3) + specifier: ^2.8.7 + version: 2.8.7(@types/node@22.15.29)(typescript@5.8.3) rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -437,8 +469,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -456,8 +488,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -475,11 +507,11 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 '@types/react': - specifier: ^19.1.5 - version: 19.1.5 + specifier: ^19.1.6 + version: 19.1.6 feature-form: specifier: workspace:* version: link:../feature-form @@ -506,8 +538,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -525,8 +557,8 @@ importers: specifier: ^1.113.0 version: 1.113.0 '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -547,8 +579,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 dotenv: specifier: ^16.5.0 version: 16.5.0 @@ -559,6 +591,19 @@ importers: specifier: workspace:* version: link:../rollup-presets + packages/head-metadata: + dependencies: + xml-tokenizer: + specifier: workspace:* + version: link:../xml-tokenizer + devDependencies: + '@types/node': + specifier: ^22.15.29 + version: 22.15.29 + rollup-presets: + specifier: workspace:* + version: link:../rollup-presets + packages/openapi-ts-router: dependencies: '@blgc/types': @@ -578,14 +623,14 @@ importers: specifier: ^5.0.6 version: 5.0.6 '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 express: specifier: ^5.1.0 version: 5.1.0 hono: - specifier: ^4.7.10 - version: 4.7.10 + specifier: ^4.7.11 + version: 4.7.11 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -602,8 +647,8 @@ importers: specifier: ^28.0.3 version: 28.0.3(rollup@4.41.1) execa: - specifier: 9.5.3 - version: 9.5.3 + specifier: 9.6.0 + version: 9.6.0 picocolors: specifier: ^1.1.1 version: 1.1.1 @@ -612,7 +657,7 @@ importers: version: 6.2.1(rollup@4.41.1)(typescript@5.8.3) rollup-plugin-esbuild: specifier: ^6.2.1 - version: 6.2.1(esbuild@0.25.4)(rollup@4.41.1) + version: 6.2.1(esbuild@0.25.5)(rollup@4.41.1) rollup-plugin-node-externals: specifier: 8.0.0 version: 8.0.0(rollup@4.41.1) @@ -621,8 +666,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup: specifier: ^4.41.1 version: 4.41.1 @@ -645,8 +690,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -664,8 +709,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -680,8 +725,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -696,8 +741,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 rollup-presets: specifier: workspace:* version: link:../rollup-presets @@ -708,8 +753,8 @@ importers: specifier: ^1.6.1 version: 1.6.1 zod: - specifier: ^3.25.28 - version: 3.25.28 + specifier: ^3.25.46 + version: 3.25.46 packages/xml-tokenizer: devDependencies: @@ -717,8 +762,8 @@ importers: specifier: workspace:* version: link:../config '@types/node': - specifier: ^22.15.21 - version: 22.15.21 + specifier: ^22.15.29 + version: 22.15.29 '@types/sax': specifier: ^1.2.7 version: 1.2.7 @@ -855,6 +900,9 @@ packages: resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} + '@blgc/utils@0.0.52': + resolution: {integrity: sha512-wshoO58fjGVbsVw8Y24vMKAHCwUdL7052/q/iESONDPd9s+Q8dEHGQTL7RGSPWFtpc8nTIeW69G8pUd1wM0ZLg==} + '@bundled-es-modules/cookie@2.0.1': resolution: {integrity: sha512-8o+5fRPLNbjbdGRRmJj3h6Hh1AQJf2dk3qQ/5ZFb+PXkRNiSoMGGUKlsgLfrxneb72axVJyIYji64E2+nNfYyw==} @@ -929,6 +977,12 @@ packages: resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==} engines: {node: '>=12'} + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.0': resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} engines: {node: '>=18'} @@ -941,6 +995,18 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.25.5': + resolution: {integrity: sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.0': resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} engines: {node: '>=18'} @@ -953,6 +1019,18 @@ packages: cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.25.5': + resolution: {integrity: sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.0': resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} engines: {node: '>=18'} @@ -965,6 +1043,18 @@ packages: cpu: [arm] os: [android] + '@esbuild/android-arm@0.25.5': + resolution: {integrity: sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.0': resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} engines: {node: '>=18'} @@ -977,6 +1067,18 @@ packages: cpu: [x64] os: [android] + '@esbuild/android-x64@0.25.5': + resolution: {integrity: sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.0': resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} engines: {node: '>=18'} @@ -989,6 +1091,18 @@ packages: cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.25.5': + resolution: {integrity: sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.0': resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} engines: {node: '>=18'} @@ -1001,6 +1115,18 @@ packages: cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.25.5': + resolution: {integrity: sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.0': resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} engines: {node: '>=18'} @@ -1013,6 +1139,18 @@ packages: cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.25.5': + resolution: {integrity: sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.0': resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} engines: {node: '>=18'} @@ -1025,6 +1163,18 @@ packages: cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.25.5': + resolution: {integrity: sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.0': resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} engines: {node: '>=18'} @@ -1037,6 +1187,18 @@ packages: cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.25.5': + resolution: {integrity: sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.0': resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} engines: {node: '>=18'} @@ -1049,6 +1211,18 @@ packages: cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.25.5': + resolution: {integrity: sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.0': resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} engines: {node: '>=18'} @@ -1061,6 +1235,18 @@ packages: cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.25.5': + resolution: {integrity: sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.0': resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} engines: {node: '>=18'} @@ -1073,6 +1259,18 @@ packages: cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.25.5': + resolution: {integrity: sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.0': resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} engines: {node: '>=18'} @@ -1085,6 +1283,18 @@ packages: cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.25.5': + resolution: {integrity: sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.0': resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} engines: {node: '>=18'} @@ -1097,6 +1307,18 @@ packages: cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.25.5': + resolution: {integrity: sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.0': resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} engines: {node: '>=18'} @@ -1109,6 +1331,18 @@ packages: cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.25.5': + resolution: {integrity: sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.0': resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} engines: {node: '>=18'} @@ -1121,6 +1355,18 @@ packages: cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.25.5': + resolution: {integrity: sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.0': resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} engines: {node: '>=18'} @@ -1133,6 +1379,12 @@ packages: cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.25.5': + resolution: {integrity: sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.0': resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} engines: {node: '>=18'} @@ -1145,6 +1397,18 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.25.5': + resolution: {integrity: sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.0': resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} engines: {node: '>=18'} @@ -1157,6 +1421,12 @@ packages: cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.25.5': + resolution: {integrity: sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.0': resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} engines: {node: '>=18'} @@ -1169,6 +1439,18 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.25.5': + resolution: {integrity: sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.0': resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} engines: {node: '>=18'} @@ -1181,6 +1463,18 @@ packages: cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.25.5': + resolution: {integrity: sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.0': resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} engines: {node: '>=18'} @@ -1193,6 +1487,18 @@ packages: cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.25.5': + resolution: {integrity: sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.0': resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} engines: {node: '>=18'} @@ -1205,6 +1511,18 @@ packages: cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.25.5': + resolution: {integrity: sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.0': resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} engines: {node: '>=18'} @@ -1217,6 +1535,18 @@ packages: cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.25.5': + resolution: {integrity: sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.0': resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} engines: {node: '>=18'} @@ -1229,6 +1559,12 @@ packages: cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.25.5': + resolution: {integrity: sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + '@eslint-community/eslint-utils@4.7.0': resolution: {integrity: sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1255,8 +1591,8 @@ packages: resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@9.27.0': - resolution: {integrity: sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==} + '@eslint/js@9.28.0': + resolution: {integrity: sha512-fnqSjGWd/CoIp4EXIxWVK/sHA6DOHN4+8Ix2cX5ycOY7LG0UY8nHCU5pIp2eaE1Mc7Qd8kHspYNzYXT2ojPLzg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.6': @@ -1270,14 +1606,14 @@ packages: '@figma/plugin-typings@1.113.0': resolution: {integrity: sha512-gasgrtK6XsZmpsWCbE1g7KTLWGCc6teo4alNUDF06OtJ7E9hwOOTMdicueAgghvQJETI+dmWE3IjJfGIPSsdfA==} - '@hono/node-server@1.14.2': - resolution: {integrity: sha512-GHjpOeHYbr9d1vkID2sNUYkl5IxumyhDrUJB7wBp7jvqYwPFt+oNKsAPBRcdSbV7kIrXhouLE199ks1QcK4r7A==} + '@hono/node-server@1.14.3': + resolution: {integrity: sha512-KuDMwwghtFYSmIpr4WrKs1VpelTrptvJ+6x6mbUcZnFcc213cumTF5BdqfHyW93B19TNI4Vaev14vOI2a0Ie3w==} engines: {node: '>=18.14.1'} peerDependencies: hono: ^4 - '@hono/zod-validator@0.5.0': - resolution: {integrity: sha512-ds5bW6DCgAnNHP33E3ieSbaZFd5dkV52ZjyaXtGoR06APFrCtzAsKZxTHwOrJNBdXsi0e5wNwo5L4nVEVnJUdg==} + '@hono/zod-validator@0.4.3': + resolution: {integrity: sha512-xIgMYXDyJ4Hj6ekm9T9Y27s080Nl9NXHcJkOvkXPhubOLj8hZkOL8pDnnXfvCf5xEE8Q4oMFenQUZZREUY2gqQ==} peerDependencies: hono: '>=3.9.0' zod: ^3.19.1 @@ -1302,17 +1638,17 @@ packages: resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} engines: {node: '>=18.18'} - '@ianvs/prettier-plugin-sort-imports@4.4.1': - resolution: {integrity: sha512-F0/Hrcfpy8WuxlQyAWJTEren/uxKhYonOGY4OyWmwRdeTvkh9mMSCxowZLjNkhwi/2ipqCgtXwwOk7tW0mWXkA==} + '@ianvs/prettier-plugin-sort-imports@4.4.2': + resolution: {integrity: sha512-KkVFy3TLh0OFzimbZglMmORi+vL/i2OFhEs5M07R9w0IwWAGpsNNyE4CY/2u0YoMF5bawKC2+8/fUH60nnNtjw==} peerDependencies: '@vue/compiler-sfc': 2.7.x || 3.x - prettier: 2 || 3 + prettier: 2 || 3 || ^4.0.0-0 peerDependenciesMeta: '@vue/compiler-sfc': optional: true - '@inquirer/confirm@5.1.7': - resolution: {integrity: sha512-Xrfbrw9eSiHb+GsesO8TQIeHSMTP0xyvTCeeYevgZ4sKW+iz9w/47bgfG9b0niQm+xaLY2EWPBINUPldLwvYiw==} + '@inquirer/confirm@5.1.12': + resolution: {integrity: sha512-dpq+ielV9/bqgXRUbNH//KsY6WEw9DrGPmipkpmgC1Y46cwuBTNx7PXFWTjc3MQ+urcc0QxoVHcMI0FW4Ok0hg==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1320,8 +1656,8 @@ packages: '@types/node': optional: true - '@inquirer/core@10.1.8': - resolution: {integrity: sha512-HpAqR8y715zPpM9e/9Q+N88bnGwqqL8ePgZ0SMv/s3673JLMv3bIkoivGmjPqXlEgisUksSXibweQccUwEx4qQ==} + '@inquirer/core@10.1.13': + resolution: {integrity: sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1329,12 +1665,12 @@ packages: '@types/node': optional: true - '@inquirer/figures@1.0.11': - resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==} + '@inquirer/figures@1.0.12': + resolution: {integrity: sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==} engines: {node: '>=18'} - '@inquirer/type@3.0.5': - resolution: {integrity: sha512-ZJpeIYYueOz/i/ONzrfof8g89kNdO2hjGuvULROo3O8rlB2CRtSseE5KeirnyE4t/thAn/EwvS/vuQeJCn+NZg==} + '@inquirer/type@3.0.7': + resolution: {integrity: sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==} engines: {node: '>=18'} peerDependencies: '@types/node': '>=18' @@ -1369,12 +1705,12 @@ packages: '@manypkg/get-packages@1.1.3': resolution: {integrity: sha512-fo+QhuU3qE/2TQMQmbVMqaQ6EWbMhi4ABWP+O4AM1NqPBuy0OrApV5LO6BrrgnhtAHS2NH6RrVk9OL181tTi8A==} - '@mswjs/interceptors@0.37.6': - resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==} + '@mswjs/interceptors@0.38.7': + resolution: {integrity: sha512-Jkb27iSn7JPdkqlTqKfhncFfnEZsIJVYxsFbUSWEkxdIPdsyngrhoDBk0/BGD2FQcRH99vlRrkHpNTyKqI+0/w==} engines: {node: '>=18'} - '@next/eslint-plugin-next@15.3.2': - resolution: {integrity: sha512-ijVRTXBgnHT33aWnDtmlG+LJD+5vhc9AKTJPquGG5NKXjpKNjc62woIhFtrAcWdBobt8kqjCoaJ0q6sDQoX7aQ==} + '@next/eslint-plugin-next@15.3.3': + resolution: {integrity: sha512-VKZJEiEdpKkfBmcokGjHu0vGDG+8CehGs90tBEy/IDoDDKGngeyIStt2MmE5FYNyU9BhgR7tybNWTAJY/30u+Q==} '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} @@ -1397,8 +1733,8 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@pkgr/core@0.2.4': - resolution: {integrity: sha512-ROFF39F6ZrnzSUEmQQZUar0Jt4xVoP9WnDRdWwF4NNcXs3xBTLgBUDoOwW141y1jP+S8nahIbdxbFC7IShw9Iw==} + '@pkgr/core@0.2.5': + resolution: {integrity: sha512-YRx7tFgLkrpFkDAzVSV5sUJydmf2ZDrW+O3IbQ1JyeMW7B0FiWroFJTnR4/fD9CsusnAn4qRUcbb5jFnZSd6uw==} engines: {node: ^12.20.0 || ^14.18.0 || >=16.0.0} '@redocly/ajv@8.11.2': @@ -1616,8 +1952,8 @@ packages: '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} - '@types/node@22.15.21': - resolution: {integrity: sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ==} + '@types/node@22.15.29': + resolution: {integrity: sha512-LNdjOkUDlU1RZb8e1kOIUpN1qQUlzGkEtbVNo53vbrwDg5om6oduhm4SiUaPW5ASTXhAiP0jInWG8Qx9fVlOeQ==} '@types/qs@6.9.18': resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==} @@ -1630,8 +1966,8 @@ packages: peerDependencies: '@types/react': ^19.0.0 - '@types/react@19.1.5': - resolution: {integrity: sha512-piErsCVVbpMMT2r7wbawdZsq4xMvIAhQuac2gedQHysu1TZYEigE6pnFfgZT+/jQnrRuF5r+SHzuehFjfRjr4g==} + '@types/react@19.1.6': + resolution: {integrity: sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==} '@types/sax@1.2.7': resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} @@ -1654,51 +1990,61 @@ packages: '@types/xml2js@0.4.14': resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==} - '@typescript-eslint/eslint-plugin@8.32.1': - resolution: {integrity: sha512-6u6Plg9nP/J1GRpe/vcjjabo6Uc5YQPAMxsgQyGC/I0RuukiG1wIe3+Vtg3IrSCVJDmqK3j8adrtzXSENRtFgg==} + '@typescript-eslint/eslint-plugin@8.33.0': + resolution: {integrity: sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 + '@typescript-eslint/parser': ^8.33.0 eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/parser@8.32.1': - resolution: {integrity: sha512-LKMrmwCPoLhM45Z00O1ulb6jwyVr2kr3XJp+G+tSEZcbauNnScewcQwtJqXDhXeYPDEjZ8C1SjXm015CirEmGg==} + '@typescript-eslint/parser@8.33.0': + resolution: {integrity: sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/scope-manager@8.32.1': - resolution: {integrity: sha512-7IsIaIDeZn7kffk7qXC3o6Z4UblZJKV3UBpkvRNpr5NSyLji7tvTcvmnMNYuYLyh26mN8W723xpo3i4MlD33vA==} + '@typescript-eslint/project-service@8.33.0': + resolution: {integrity: sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/scope-manager@8.33.0': + resolution: {integrity: sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.33.0': + resolution: {integrity: sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/type-utils@8.32.1': - resolution: {integrity: sha512-mv9YpQGA8iIsl5KyUPi+FGLm7+bA4fgXaeRcFKRDRwDMu4iwrSHeDPipwueNXhdIIZltwCJv+NkxftECbIZWfA==} + '@typescript-eslint/type-utils@8.33.0': + resolution: {integrity: sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/types@8.32.1': - resolution: {integrity: sha512-YmybwXUJcgGqgAp6bEsgpPXEg6dcCyPyCSr0CAAueacR/CCBi25G3V8gGQ2kRzQRBNol7VQknxMs9HvVa9Rvfg==} + '@typescript-eslint/types@8.33.0': + resolution: {integrity: sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.32.1': - resolution: {integrity: sha512-Y3AP9EIfYwBb4kWGb+simvPaqQoT5oJuzzj9m0i6FCY6SPvlomY2Ei4UEMm7+FXtlNJbor80ximyslzaQF6xhg==} + '@typescript-eslint/typescript-estree@8.33.0': + resolution: {integrity: sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.32.1': - resolution: {integrity: sha512-DsSFNIgLSrc89gpq1LJB7Hm1YpuhK086DRDJSNrewcGvYloWW1vZLHBTIvarKZDcAORIy/uWNx8Gad+4oMpkSA==} + '@typescript-eslint/utils@8.33.0': + resolution: {integrity: sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@8.32.1': - resolution: {integrity: sha512-ar0tjQfObzhSaW3C3QNmTc5ofj0hDoNQ5XWrCy6zDyabdr0TWhCkClp+rywGNj/odAFBVzzJrK4tEq5M4Hmu4w==} + '@typescript-eslint/visitor-keys@8.33.0': + resolution: {integrity: sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@vitejs/plugin-react@4.5.0': @@ -1736,6 +2082,10 @@ packages: '@vitest/utils@3.1.4': resolution: {integrity: sha512-yriMuO1cfFhmiGc8ataN51+9ooHRuURdfAZfwFd3usWynjzpLslZdYnRegTv32qdgtJTsj15FoeZe2g15fY1gg==} + accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -1794,6 +2144,9 @@ packages: resolution: {integrity: sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==} engines: {node: '>= 0.4'} + array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + array-includes@3.1.8: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} @@ -1848,6 +2201,14 @@ packages: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} + bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: + resolution: {tarball: https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f} + version: 0.4.0 + + body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + body-parser@2.2.0: resolution: {integrity: sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==} engines: {node: '>=18'} @@ -1960,6 +2321,10 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + content-disposition@1.0.0: resolution: {integrity: sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==} engines: {node: '>= 0.6'} @@ -1971,6 +2336,9 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -2018,6 +2386,14 @@ packages: dataloader@1.4.0: resolution: {integrity: sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw==} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.0: resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} engines: {node: '>=6.0'} @@ -2071,6 +2447,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + detect-indent@6.1.0: resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==} engines: {node: '>=8'} @@ -2120,6 +2500,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + encodeurl@2.0.0: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} @@ -2174,6 +2558,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.0: resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} engines: {node: '>=18'} @@ -2184,6 +2573,11 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.25.5: + resolution: {integrity: sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==} + engines: {node: '>=18'} + hasBin: true + escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2222,8 +2616,8 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 - eslint-plugin-turbo@2.5.3: - resolution: {integrity: sha512-DlXZd+LgpDlxH/6IsiAXLhy82x0jeJDm0XBEqP6Le08uy0HBQkjCUt7SmXNp8esAtX9RYe6oDClbNbmI1jtK5g==} + eslint-plugin-turbo@2.5.4: + resolution: {integrity: sha512-IZsW61DFj5mLMMaCJxhh1VE4HvNhfdnHnAaXajgne+LUzdyHk2NvYT0ECSa/1SssArcqgTvV74MrLL68hWLLFw==} peerDependencies: eslint: '>6.6.0' turbo: '>2.0.0' @@ -2240,8 +2634,8 @@ packages: resolution: {integrity: sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@9.27.0: - resolution: {integrity: sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==} + eslint@9.28.0: + resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -2292,14 +2686,18 @@ packages: resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} engines: {node: '>=6'} - execa@9.5.3: - resolution: {integrity: sha512-QFNnTvU3UjgWFy8Ef9iDHvIdcgZ344ebkwYx4/KLbR+CKQA4xBaHzv+iRpp86QfMHP8faFQLh8iOc57215y4Rg==} + execa@9.6.0: + resolution: {integrity: sha512-jpWzZ1ZhwUmeWRhS7Qv3mhpOhLfwI+uAX4e5fOcXqwMR7EcJ0pj2kV1CVzHVMX/LphnKWD3LObjZCoJ71lKpHw==} engines: {node: ^18.19.0 || >=20.5.0} expect-type@1.2.1: resolution: {integrity: sha512-/kP8CAwxzLVEeFrMm4kMmy4CCDlpipyA7MYLVrdJIkV0fYF0UaigQHRsxHiuY/GEea+bh4KSv3TIlgr+2UL6bw==} engines: {node: '>=12.0.0'} + express@4.21.2: + resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==} + engines: {node: '>= 0.10.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -2328,6 +2726,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fast-xml-parser@4.5.3: + resolution: {integrity: sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==} + hasBin: true + fast-xml-parser@5.2.3: resolution: {integrity: sha512-OdCYfRqfpuLUFonTNjvd30rCBZUneHpSQkCqfaeWQ9qrKcl6XlWeDBNVwGb+INAIxRshuN2jF+BE0L6gbBO2mw==} hasBin: true @@ -2351,6 +2753,14 @@ packages: picomatch: optional: true + fdir@6.4.5: + resolution: {integrity: sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -2363,6 +2773,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + finalhandler@2.1.0: resolution: {integrity: sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==} engines: {node: '>= 0.8'} @@ -2390,6 +2804,10 @@ packages: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} + fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + fresh@2.0.0: resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} engines: {node: '>= 0.8'} @@ -2488,8 +2906,8 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - graphql@16.10.0: - resolution: {integrity: sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ==} + graphql@16.11.0: + resolution: {integrity: sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} has-bigints@1.1.0: @@ -2532,8 +2950,8 @@ packages: headers-polyfill@4.0.3: resolution: {integrity: sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==} - hono@4.7.10: - resolution: {integrity: sha512-QkACju9MiN59CKSY5JsGZCYmPZkA6sIW6OFCUp7qDjZu6S6KHtJHhAc9Uy9mV9F8PJ1/HQ3ybZF2yjCa/73fvQ==} + hono@4.7.11: + resolution: {integrity: sha512-rv0JMwC0KALbbmwJDEnxvQCeJh+xbS3KEWW5PC9cMJ08Ur9xgatI0HmtgYZfOdOSOeYsp5LO2cOhdI8cLEbDEQ==} engines: {node: '>=16.9.0'} http-errors@2.0.0: @@ -2548,8 +2966,8 @@ packages: resolution: {integrity: sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==} hasBin: true - human-signals@8.0.0: - resolution: {integrity: sha512-/1/GPCpDUCCYwlERiYjxoczfP0zfvZMU/OWgQPMya9AbAE24vseigFdhAMObpc8Q4lc/kjutPfUddDYyAmejnA==} + human-signals@8.0.1: + resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==} engines: {node: '>=18.18.0'} iconv-lite@0.4.24: @@ -2861,10 +3279,17 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + media-typer@1.1.0: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -2873,18 +3298,35 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} + mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -2903,11 +3345,14 @@ packages: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - msw@2.8.4: - resolution: {integrity: sha512-GLU8gx0o7RBG/3x/eTnnLd5S5ZInxXRRRMN8GJwaPZ4jpJTxzQfWGvwr90e8L5dkKJnz+gT4gQYCprLy/c4kVw==} + msw@2.8.7: + resolution: {integrity: sha512-0TGfV4oQiKpa3pDsQBDf0xvFP+sRrqEOnh2n1JWpHVKHJHLv6ZmY1HCZpCi7uDiJTeIHJMBpmBiRmBJN+ETPSQ==} engines: {node: '>=18'} hasBin: true peerDependencies: @@ -2936,6 +3381,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -3123,6 +3572,9 @@ packages: path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + path-to-regexp@0.1.12: + resolution: {integrity: sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==} + path-to-regexp@6.3.0: resolution: {integrity: sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==} @@ -3179,8 +3631,8 @@ packages: peerDependencies: postcss: ^8.4.29 - postcss@8.5.3: - resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + postcss@8.5.4: + resolution: {integrity: sha512-QSa9EBe+uwlGTFmHsPKokv3B/oEMQZxfqW0QqNCyhpa6mB1afzulwn8hihglqAb2pOw+BJgNlmXQ8la2VeHB7w==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -3193,16 +3645,16 @@ packages: peerDependencies: prettier: 3.x - prettier-plugin-packagejson@2.5.14: - resolution: {integrity: sha512-h+3tSpr2nVpp+YOK1MDIYtYhHVXr8/0V59UUbJpIJFaqi3w4fvUokJo6eV8W+vELrUXIZzJ+DKm5G7lYzrMcKQ==} + prettier-plugin-packagejson@2.5.15: + resolution: {integrity: sha512-2QSx6y4IT6LTwXtCvXAopENW5IP/aujC8fobEM2pDbs0IGkiVjW/ipPuYAHuXigbNe64aGWF7vIetukuzM3CBw==} peerDependencies: prettier: '>= 1.16.0' peerDependenciesMeta: prettier: optional: true - prettier-plugin-tailwindcss@0.6.11: - resolution: {integrity: sha512-YxaYSIvZPAqhrrEpRtonnrXdghZg1irNg4qrjboCXrpybLWVs55cW2N3juhspVJiO0JBvYJT8SYsJpc8OQSnsA==} + prettier-plugin-tailwindcss@0.6.12: + resolution: {integrity: sha512-OuTQKoqNwV7RnxTPwXWzOFXy6Jc4z8oeRZYGuMpRyG3WbuR3jjXdQFK8qFBMBx8UHWdHrddARz2fgUenild6aw==} engines: {node: '>=14.21.3'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -3293,6 +3745,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + qs@6.14.0: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} @@ -3310,6 +3766,10 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} + raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + raw-body@3.0.0: resolution: {integrity: sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==} engines: {node: '>= 0.8'} @@ -3475,10 +3935,18 @@ packages: engines: {node: '>=10'} hasBin: true + send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + send@1.2.0: resolution: {integrity: sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==} engines: {node: '>= 18'} + serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + serve-static@2.2.0: resolution: {integrity: sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==} engines: {node: '>= 18'} @@ -3639,6 +4107,9 @@ packages: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} + strnum@1.1.2: + resolution: {integrity: sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==} + strnum@2.1.0: resolution: {integrity: sha512-w0S//9BqZZGw0L0Y8uLSelFGnDJgTyyNQLmSlPnVz43zPAiqu3w4t8J8sDqqANOGeZIZ/9jWuPguYcEnsoHv4A==} @@ -3658,8 +4129,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - synckit@0.11.6: - resolution: {integrity: sha512-2pR2ubZSV64f/vqm9eLPz/KOvR9Dm+Co/5ChLgeHl0yEDRc6h5hXHoxEQH8Y5Ljycozd3p1k5TTSVdzYGkPvLw==} + synckit@0.11.8: + resolution: {integrity: sha512-+XZ+r1XGIJGeQk3VvXhT6xx/VpbHsRzsTkGgF6E5RX9TTXD0118l87puaEBZ566FhqblC6U0d4XnubznJDm30A==} engines: {node: ^14.18.0 || >=16.0.0} term-size@2.2.1: @@ -3675,10 +4146,6 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinybench@4.0.1: - resolution: {integrity: sha512-Nb1srn7dvzkVx0J5h1vq8f48e3TIcbrS7e/UfAI/cDSef/n8yLh4zsAEsFkfpw6auTY+ZaspEvam/xs8nMnotQ==} - engines: {node: '>=18.0.0'} - tinyexec@0.3.2: resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} @@ -3686,6 +4153,10 @@ packages: resolution: {integrity: sha512-mEwzpUgrLySlveBwEVDMKk5B57bhLPYovRfPAXD5gA/98Opn0rCDj3GtLwFvCvH5RK9uPCExUROW5NjDwvqkxw==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.14: + resolution: {integrity: sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==} + engines: {node: '>=12.0.0'} + tinypool@1.0.2: resolution: {integrity: sha512-al6n+QEANGFOMf/dmUMsuS5/r9B06uwlyNjZZql/zv8J7ybHCgoihBNORZCY2mzUuAnomQa2JdhyHKzZxPCrFA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -3763,38 +4234,38 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo-darwin-64@2.5.3: - resolution: {integrity: sha512-YSItEVBUIvAGPUDpAB9etEmSqZI3T6BHrkBkeSErvICXn3dfqXUfeLx35LfptLDEbrzFUdwYFNmt8QXOwe9yaw==} + turbo-darwin-64@2.5.4: + resolution: {integrity: sha512-ah6YnH2dErojhFooxEzmvsoZQTMImaruZhFPfMKPBq8sb+hALRdvBNLqfc8NWlZq576FkfRZ/MSi4SHvVFT9PQ==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.5.3: - resolution: {integrity: sha512-5PefrwHd42UiZX7YA9m1LPW6x9YJBDErXmsegCkVp+GjmWrADfEOxpFrGQNonH3ZMj77WZB2PVE5Aw3gA+IOhg==} + turbo-darwin-arm64@2.5.4: + resolution: {integrity: sha512-2+Nx6LAyuXw2MdXb7pxqle3MYignLvS7OwtsP9SgtSBaMlnNlxl9BovzqdYAgkUW3AsYiQMJ/wBRb7d+xemM5A==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.5.3: - resolution: {integrity: sha512-M9xigFgawn5ofTmRzvjjLj3Lqc05O8VHKuOlWNUlnHPUltFquyEeSkpQNkE/vpPdOR14AzxqHbhhxtfS4qvb1w==} + turbo-linux-64@2.5.4: + resolution: {integrity: sha512-5May2kjWbc8w4XxswGAl74GZ5eM4Gr6IiroqdLhXeXyfvWEdm2mFYCSWOzz0/z5cAgqyGidF1jt1qzUR8hTmOA==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.5.3: - resolution: {integrity: sha512-auJRbYZ8SGJVqvzTikpg1bsRAsiI9Tk0/SDkA5Xgg0GdiHDH/BOzv1ZjDE2mjmlrO/obr19Dw+39OlMhwLffrw==} + turbo-linux-arm64@2.5.4: + resolution: {integrity: sha512-/2yqFaS3TbfxV3P5yG2JUI79P7OUQKOUvAnx4MV9Bdz6jqHsHwc9WZPpO4QseQm+NvmgY6ICORnoVPODxGUiJg==} cpu: [arm64] os: [linux] - turbo-windows-64@2.5.3: - resolution: {integrity: sha512-arLQYohuHtIEKkmQSCU9vtrKUg+/1TTstWB9VYRSsz+khvg81eX6LYHtXJfH/dK7Ho6ck+JaEh5G+QrE1jEmCQ==} + turbo-windows-64@2.5.4: + resolution: {integrity: sha512-EQUO4SmaCDhO6zYohxIjJpOKRN3wlfU7jMAj3CgcyTPvQR/UFLEKAYHqJOnJtymbQmiiM/ihX6c6W6Uq0yC7mA==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.5.3: - resolution: {integrity: sha512-3JPn66HAynJ0gtr6H+hjY4VHpu1RPKcEwGATvGUTmLmYSYBQieVlnGDRMMoYN066YfyPqnNGCfhYbXfH92Cm0g==} + turbo-windows-arm64@2.5.4: + resolution: {integrity: sha512-oQ8RrK1VS8lrxkLriotFq+PiF7iiGgkZtfLKF4DDKsmdbPo0O9R2mQxm7jHLuXraRCuIQDWMIw6dpcr7Iykf4A==} cpu: [arm64] os: [win32] - turbo@2.5.3: - resolution: {integrity: sha512-iHuaNcq5GZZnr3XDZNuu2LSyCzAOPwDuo5Qt+q64DfsTP1i3T2bKfxJhni2ZQxsvAoxRbuUK5QetJki4qc5aYA==} + turbo@2.5.4: + resolution: {integrity: sha512-kc8ZibdRcuWUG1pbYSBFWqmIjynlD8Lp7IB6U3vIzvOv9VG+6Sp8bzyeBWE3Oi8XV5KsQrznyRTBPvrf99E4mA==} hasBin: true txml@5.1.1: @@ -3816,6 +4287,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + type-is@2.0.1: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} @@ -3836,8 +4311,8 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typescript-eslint@8.32.1: - resolution: {integrity: sha512-D7el+eaDHAmXvrZBy1zpzSNIRqnCOrkwTgZxTu3MUqRWk8k0q9m9Ho4+vPf7iHtgUfrK/o8IZaEApsxPlHTFCg==} + typescript-eslint@8.33.0: + resolution: {integrity: sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -3896,9 +4371,29 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} + valibot@1.0.0-beta.12: + resolution: {integrity: sha512-j3WIxJ0pmUFMfdfUECn3YnZPYOiG0yHYcFEa/+RVgo0I+MXE3ToLt7gNRLtY5pwGfgNmsmhenGZfU5suu9ijUA==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + + valibot@1.0.0-beta.9: + resolution: {integrity: sha512-yEX8gMAZ2R1yI2uwOO4NCtVnJQx36zn3vD0omzzj9FhcoblvPukENIiRZXKZwCnqSeV80bMm8wNiGhQ0S8fiww==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + valibot@1.1.0: resolution: {integrity: sha512-Nk8lX30Qhu+9txPYTwM0cFlWLdPFsFr6LblzqIySfbZph9+BFsAHsNvHOymEviUepeIW6KFHzpX8TKhbptBXXw==} peerDependencies: @@ -3915,13 +4410,44 @@ packages: resolution: {integrity: sha512-6enNwYnpyDo4hEgytbmc6mYWHXDHYEn0D1/rw4Q+tnHUGtKTJsn8T1YkX6Q18wI5LCrS8CTYlBaiCqxOy2kvUA==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} hasBin: true - - vite-tsconfig-paths@5.1.4: - resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + + vite-tsconfig-paths@5.1.4: + resolution: {integrity: sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + + vite@5.4.19: + resolution: {integrity: sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true peerDependencies: - vite: '*' + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 peerDependenciesMeta: - vite: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: optional: true vite@6.3.5: @@ -4088,14 +4614,14 @@ packages: yup@1.6.1: resolution: {integrity: sha512-JED8pB50qbA4FOkDol0bYF/p60qSEDQqBD0/qeIrUCG1KbPBIQ776fCUNb9ldbPcSTxA69g/47XTo4TqWiuXOA==} - zod@3.25.28: - resolution: {integrity: sha512-/nt/67WYKnr5by3YS7LroZJbtcCBurDKKPBPWWzaxvVCGuG/NOsiKkrjoOhI8mJ+SQUXEbUzeB3S+6XDUEEj7Q==} + zod@3.25.46: + resolution: {integrity: sha512-IqRxcHEIjqLd4LNS/zKffB3Jzg3NwqJxQQ0Ns7pdrvgGkwQsEBdEQcOHaBVqvvZArShRzI39+aMST3FBGmTrLQ==} snapshots: - '@0no-co/graphql.web@1.1.2(graphql@16.10.0)': + '@0no-co/graphql.web@1.1.2(graphql@16.11.0)': optionalDependencies: - graphql: 16.10.0 + graphql: 16.11.0 '@ampproject/remapping@2.3.0': dependencies: @@ -4226,6 +4752,8 @@ snapshots: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 + '@blgc/utils@0.0.52': {} + '@bundled-es-modules/cookie@2.0.1': dependencies: cookie: 0.7.2 @@ -4400,159 +4928,303 @@ snapshots: dependencies: '@jridgewell/trace-mapping': 0.3.9 + '@esbuild/aix-ppc64@0.21.5': + optional: true + '@esbuild/aix-ppc64@0.25.0': optional: true '@esbuild/aix-ppc64@0.25.4': optional: true + '@esbuild/aix-ppc64@0.25.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + '@esbuild/android-arm64@0.25.0': optional: true '@esbuild/android-arm64@0.25.4': optional: true + '@esbuild/android-arm64@0.25.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + '@esbuild/android-arm@0.25.0': optional: true '@esbuild/android-arm@0.25.4': optional: true + '@esbuild/android-arm@0.25.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + '@esbuild/android-x64@0.25.0': optional: true '@esbuild/android-x64@0.25.4': optional: true + '@esbuild/android-x64@0.25.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + '@esbuild/darwin-arm64@0.25.0': optional: true '@esbuild/darwin-arm64@0.25.4': optional: true + '@esbuild/darwin-arm64@0.25.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + '@esbuild/darwin-x64@0.25.0': optional: true '@esbuild/darwin-x64@0.25.4': optional: true + '@esbuild/darwin-x64@0.25.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + '@esbuild/freebsd-arm64@0.25.0': optional: true '@esbuild/freebsd-arm64@0.25.4': optional: true + '@esbuild/freebsd-arm64@0.25.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + '@esbuild/freebsd-x64@0.25.0': optional: true '@esbuild/freebsd-x64@0.25.4': optional: true + '@esbuild/freebsd-x64@0.25.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + '@esbuild/linux-arm64@0.25.0': optional: true '@esbuild/linux-arm64@0.25.4': optional: true + '@esbuild/linux-arm64@0.25.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + '@esbuild/linux-arm@0.25.0': optional: true '@esbuild/linux-arm@0.25.4': optional: true + '@esbuild/linux-arm@0.25.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + '@esbuild/linux-ia32@0.25.0': optional: true '@esbuild/linux-ia32@0.25.4': optional: true + '@esbuild/linux-ia32@0.25.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + '@esbuild/linux-loong64@0.25.0': optional: true '@esbuild/linux-loong64@0.25.4': optional: true + '@esbuild/linux-loong64@0.25.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + '@esbuild/linux-mips64el@0.25.0': optional: true '@esbuild/linux-mips64el@0.25.4': optional: true + '@esbuild/linux-mips64el@0.25.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + '@esbuild/linux-ppc64@0.25.0': optional: true '@esbuild/linux-ppc64@0.25.4': optional: true + '@esbuild/linux-ppc64@0.25.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + '@esbuild/linux-riscv64@0.25.0': optional: true '@esbuild/linux-riscv64@0.25.4': optional: true + '@esbuild/linux-riscv64@0.25.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + '@esbuild/linux-s390x@0.25.0': optional: true '@esbuild/linux-s390x@0.25.4': optional: true + '@esbuild/linux-s390x@0.25.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + '@esbuild/linux-x64@0.25.0': optional: true '@esbuild/linux-x64@0.25.4': optional: true + '@esbuild/linux-x64@0.25.5': + optional: true + '@esbuild/netbsd-arm64@0.25.0': optional: true '@esbuild/netbsd-arm64@0.25.4': optional: true + '@esbuild/netbsd-arm64@0.25.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + '@esbuild/netbsd-x64@0.25.0': optional: true '@esbuild/netbsd-x64@0.25.4': optional: true + '@esbuild/netbsd-x64@0.25.5': + optional: true + '@esbuild/openbsd-arm64@0.25.0': optional: true '@esbuild/openbsd-arm64@0.25.4': optional: true + '@esbuild/openbsd-arm64@0.25.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + '@esbuild/openbsd-x64@0.25.0': optional: true '@esbuild/openbsd-x64@0.25.4': optional: true + '@esbuild/openbsd-x64@0.25.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + '@esbuild/sunos-x64@0.25.0': optional: true '@esbuild/sunos-x64@0.25.4': optional: true + '@esbuild/sunos-x64@0.25.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + '@esbuild/win32-arm64@0.25.0': optional: true '@esbuild/win32-arm64@0.25.4': optional: true + '@esbuild/win32-arm64@0.25.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + '@esbuild/win32-ia32@0.25.0': optional: true '@esbuild/win32-ia32@0.25.4': optional: true + '@esbuild/win32-ia32@0.25.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + '@esbuild/win32-x64@0.25.0': optional: true '@esbuild/win32-x64@0.25.4': optional: true - '@eslint-community/eslint-utils@4.7.0(eslint@9.27.0(jiti@2.4.2))': + '@esbuild/win32-x64@0.25.5': + optional: true + + '@eslint-community/eslint-utils@4.7.0(eslint@9.28.0(jiti@2.4.2))': dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) eslint-visitor-keys: 3.4.3 '@eslint-community/regexpp@4.12.1': {} @@ -4585,7 +5257,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@9.27.0': {} + '@eslint/js@9.28.0': {} '@eslint/object-schema@2.1.6': {} @@ -4596,14 +5268,14 @@ snapshots: '@figma/plugin-typings@1.113.0': {} - '@hono/node-server@1.14.2(hono@4.7.10)': + '@hono/node-server@1.14.3(hono@4.7.11)': dependencies: - hono: 4.7.10 + hono: 4.7.11 - '@hono/zod-validator@0.5.0(hono@4.7.10)(zod@3.25.28)': + '@hono/zod-validator@0.4.3(hono@4.7.11)(zod@3.25.46)': dependencies: - hono: 4.7.10 - zod: 3.25.28 + hono: 4.7.11 + zod: 3.25.46 '@humanfs/core@0.19.1': {} @@ -4618,7 +5290,7 @@ snapshots: '@humanwhocodes/retry@0.4.3': {} - '@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3)': + '@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3)': dependencies: '@babel/generator': 7.27.1 '@babel/parser': 7.27.2 @@ -4629,17 +5301,17 @@ snapshots: transitivePeerDependencies: - supports-color - '@inquirer/confirm@5.1.7(@types/node@22.15.21)': + '@inquirer/confirm@5.1.12(@types/node@22.15.29)': dependencies: - '@inquirer/core': 10.1.8(@types/node@22.15.21) - '@inquirer/type': 3.0.5(@types/node@22.15.21) + '@inquirer/core': 10.1.13(@types/node@22.15.29) + '@inquirer/type': 3.0.7(@types/node@22.15.29) optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@inquirer/core@10.1.8(@types/node@22.15.21)': + '@inquirer/core@10.1.13(@types/node@22.15.29)': dependencies: - '@inquirer/figures': 1.0.11 - '@inquirer/type': 3.0.5(@types/node@22.15.21) + '@inquirer/figures': 1.0.12 + '@inquirer/type': 3.0.7(@types/node@22.15.29) ansi-escapes: 4.3.2 cli-width: 4.1.0 mute-stream: 2.0.0 @@ -4647,13 +5319,13 @@ snapshots: wrap-ansi: 6.2.0 yoctocolors-cjs: 2.1.2 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@inquirer/figures@1.0.11': {} + '@inquirer/figures@1.0.12': {} - '@inquirer/type@3.0.5(@types/node@22.15.21)': + '@inquirer/type@3.0.7(@types/node@22.15.29)': optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@jridgewell/gen-mapping@0.3.8': dependencies: @@ -4693,7 +5365,7 @@ snapshots: globby: 11.1.0 read-yaml-file: 1.1.0 - '@mswjs/interceptors@0.37.6': + '@mswjs/interceptors@0.38.7': dependencies: '@open-draft/deferred-promise': 2.2.0 '@open-draft/logger': 0.3.0 @@ -4702,7 +5374,7 @@ snapshots: outvariant: 1.4.3 strict-event-emitter: 0.5.1 - '@next/eslint-plugin-next@15.3.2': + '@next/eslint-plugin-next@15.3.3': dependencies: fast-glob: 3.3.1 @@ -4727,7 +5399,7 @@ snapshots: '@open-draft/until@2.1.0': {} - '@pkgr/core@0.2.4': {} + '@pkgr/core@0.2.5': {} '@redocly/ajv@8.11.2': dependencies: @@ -4892,11 +5564,11 @@ snapshots: '@types/body-parser@1.19.5': dependencies: '@types/connect': 3.4.38 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/connect@3.4.38': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/cookie@0.6.0': {} @@ -4904,7 +5576,7 @@ snapshots: '@types/express-serve-static-core@5.0.6': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/qs': 6.9.18 '@types/range-parser': 1.2.7 '@types/send': 0.17.4 @@ -4923,7 +5595,7 @@ snapshots: '@types/node@12.20.55': {} - '@types/node@22.15.21': + '@types/node@22.15.29': dependencies: undici-types: 6.21.0 @@ -4931,27 +5603,27 @@ snapshots: '@types/range-parser@1.2.7': {} - '@types/react-dom@19.1.5(@types/react@19.1.5)': + '@types/react-dom@19.1.5(@types/react@19.1.6)': dependencies: - '@types/react': 19.1.5 + '@types/react': 19.1.6 - '@types/react@19.1.5': + '@types/react@19.1.6': dependencies: csstype: 3.1.3 '@types/sax@1.2.7': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/serve-static@1.15.7': dependencies: '@types/http-errors': 2.0.4 - '@types/node': 22.15.21 + '@types/node': 22.15.29 '@types/send': 0.17.4 '@types/statuses@2.0.5': {} @@ -4962,17 +5634,17 @@ snapshots: '@types/xml2js@0.4.14': dependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 - '@typescript-eslint/eslint-plugin@8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/eslint-plugin@8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/type-utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.32.1 - eslint: 9.27.0(jiti@2.4.2) + '@typescript-eslint/parser': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/type-utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.0 + eslint: 9.28.0(jiti@2.4.2) graphemer: 1.4.0 ignore: 7.0.4 natural-compare: 1.4.0 @@ -4981,40 +5653,55 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + '@typescript-eslint/visitor-keys': 8.33.0 debug: 4.4.0(supports-color@5.5.0) - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/scope-manager@8.32.1': + '@typescript-eslint/project-service@8.33.0(typescript@5.8.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.33.0(typescript@5.8.3) + '@typescript-eslint/types': 8.33.0 + debug: 4.4.1(supports-color@10.0.0) + transitivePeerDependencies: + - supports-color + - typescript + + '@typescript-eslint/scope-manager@8.33.0': + dependencies: + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/visitor-keys': 8.33.0 + + '@typescript-eslint/tsconfig-utils@8.33.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + typescript: 5.8.3 - '@typescript-eslint/type-utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/type-utils@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) debug: 4.4.1(supports-color@10.0.0) - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) ts-api-utils: 2.1.0(typescript@5.8.3) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/types@8.32.1': {} + '@typescript-eslint/types@8.33.0': {} - '@typescript-eslint/typescript-estree@8.32.1(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.33.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/visitor-keys': 8.32.1 + '@typescript-eslint/project-service': 8.33.0(typescript@5.8.3) + '@typescript-eslint/tsconfig-utils': 8.33.0(typescript@5.8.3) + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/visitor-keys': 8.33.0 debug: 4.4.0(supports-color@5.5.0) fast-glob: 3.3.3 is-glob: 4.0.3 @@ -5025,23 +5712,23 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3)': + '@typescript-eslint/utils@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3)': dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) - '@typescript-eslint/scope-manager': 8.32.1 - '@typescript-eslint/types': 8.32.1 - '@typescript-eslint/typescript-estree': 8.32.1(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) + '@typescript-eslint/scope-manager': 8.33.0 + '@typescript-eslint/types': 8.33.0 + '@typescript-eslint/typescript-estree': 8.33.0(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.32.1': + '@typescript-eslint/visitor-keys@8.33.0': dependencies: - '@typescript-eslint/types': 8.32.1 + '@typescript-eslint/types': 8.33.0 eslint-visitor-keys: 4.2.0 - '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4))': + '@vitejs/plugin-react@4.5.0(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4))': dependencies: '@babel/core': 7.27.1 '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.27.1) @@ -5049,7 +5736,7 @@ snapshots: '@rolldown/pluginutils': 1.0.0-beta.9 '@types/babel__core': 7.20.5 react-refresh: 0.17.0 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - supports-color @@ -5060,14 +5747,14 @@ snapshots: chai: 5.2.0 tinyrainbow: 2.0.0 - '@vitest/mocker@3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4))': + '@vitest/mocker@3.1.4(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4))': dependencies: '@vitest/spy': 3.1.4 estree-walker: 3.0.3 magic-string: 0.30.17 optionalDependencies: - msw: 2.8.4(@types/node@22.15.21)(typescript@5.8.3) - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + msw: 2.8.7(@types/node@22.15.29)(typescript@5.8.3) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) '@vitest/pretty-format@3.1.4': dependencies: @@ -5094,6 +5781,11 @@ snapshots: loupe: 3.1.3 tinyrainbow: 2.0.0 + accepts@1.3.8: + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + accepts@2.0.0: dependencies: mime-types: 3.0.1 @@ -5148,6 +5840,8 @@ snapshots: call-bound: 1.0.4 is-array-buffer: 3.0.5 + array-flatten@1.1.1: {} + array-includes@3.1.8: dependencies: call-bind: 1.0.8 @@ -5218,6 +5912,25 @@ snapshots: binary-extensions@2.3.0: {} + bitecs@https://codeload.github.com/NateTheGreatt/bitECS/tar.gz/caa1f58be2ccc304c1f0a085de34ca5904b3b80f: {} + + body-parser@1.20.3: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + body-parser@2.2.0: dependencies: bytes: 3.1.2 @@ -5344,6 +6057,10 @@ snapshots: concat-map@0.0.1: {} + content-disposition@0.5.4: + dependencies: + safe-buffer: 5.2.1 + content-disposition@1.0.0: dependencies: safe-buffer: 5.2.1 @@ -5352,6 +6069,8 @@ snapshots: convert-source-map@2.0.0: {} + cookie-signature@1.0.6: {} + cookie-signature@1.2.2: {} cookie@0.7.1: {} @@ -5374,9 +6093,9 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-declaration-sorter@7.2.0(postcss@8.5.3): + css-declaration-sorter@7.2.0(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 csstype@3.1.3: {} @@ -5400,6 +6119,10 @@ snapshots: dataloader@1.4.0: {} + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.0(supports-color@5.5.0): dependencies: ms: 2.1.3 @@ -5441,6 +6164,8 @@ snapshots: depd@2.0.0: {} + destroy@1.2.0: {} + detect-indent@6.1.0: {} detect-indent@7.0.1: {} @@ -5475,6 +6200,8 @@ snapshots: emoji-regex@8.0.0: {} + encodeurl@1.0.2: {} + encodeurl@2.0.0: {} end-of-stream@1.4.4: @@ -5594,6 +6321,32 @@ snapshots: picomatch: 4.0.2 yargs: 17.7.2 + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + esbuild@0.25.0: optionalDependencies: '@esbuild/aix-ppc64': 0.25.0 @@ -5650,27 +6403,55 @@ snapshots: '@esbuild/win32-ia32': 0.25.4 '@esbuild/win32-x64': 0.25.4 + esbuild@0.25.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.5 + '@esbuild/android-arm': 0.25.5 + '@esbuild/android-arm64': 0.25.5 + '@esbuild/android-x64': 0.25.5 + '@esbuild/darwin-arm64': 0.25.5 + '@esbuild/darwin-x64': 0.25.5 + '@esbuild/freebsd-arm64': 0.25.5 + '@esbuild/freebsd-x64': 0.25.5 + '@esbuild/linux-arm': 0.25.5 + '@esbuild/linux-arm64': 0.25.5 + '@esbuild/linux-ia32': 0.25.5 + '@esbuild/linux-loong64': 0.25.5 + '@esbuild/linux-mips64el': 0.25.5 + '@esbuild/linux-ppc64': 0.25.5 + '@esbuild/linux-riscv64': 0.25.5 + '@esbuild/linux-s390x': 0.25.5 + '@esbuild/linux-x64': 0.25.5 + '@esbuild/netbsd-arm64': 0.25.5 + '@esbuild/netbsd-x64': 0.25.5 + '@esbuild/openbsd-arm64': 0.25.5 + '@esbuild/openbsd-x64': 0.25.5 + '@esbuild/sunos-x64': 0.25.5 + '@esbuild/win32-arm64': 0.25.5 + '@esbuild/win32-ia32': 0.25.5 + '@esbuild/win32-x64': 0.25.5 + escalade@3.2.0: {} escape-html@1.0.3: {} escape-string-regexp@4.0.0: {} - eslint-config-prettier@10.1.5(eslint@9.27.0(jiti@2.4.2)): + eslint-config-prettier@10.1.5(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) eslint-plugin-only-warn@1.1.0: {} - eslint-plugin-react-hooks@5.2.0(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react-hooks@5.2.0(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) - eslint-plugin-react-refresh@0.4.20(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react-refresh@0.4.20(eslint@9.28.0(jiti@2.4.2)): dependencies: - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) - eslint-plugin-react@7.37.5(eslint@9.27.0(jiti@2.4.2)): + eslint-plugin-react@7.37.5(eslint@9.28.0(jiti@2.4.2)): dependencies: array-includes: 3.1.8 array.prototype.findlast: 1.2.5 @@ -5678,7 +6459,7 @@ snapshots: array.prototype.tosorted: 1.1.4 doctrine: 2.1.0 es-iterator-helpers: 1.2.1 - eslint: 9.27.0(jiti@2.4.2) + eslint: 9.28.0(jiti@2.4.2) estraverse: 5.3.0 hasown: 2.0.2 jsx-ast-utils: 3.3.5 @@ -5692,11 +6473,11 @@ snapshots: string.prototype.matchall: 4.0.12 string.prototype.repeat: 1.0.0 - eslint-plugin-turbo@2.5.3(eslint@9.27.0(jiti@2.4.2))(turbo@2.5.3): + eslint-plugin-turbo@2.5.4(eslint@9.28.0(jiti@2.4.2))(turbo@2.5.4): dependencies: dotenv: 16.0.3 - eslint: 9.27.0(jiti@2.4.2) - turbo: 2.5.3 + eslint: 9.28.0(jiti@2.4.2) + turbo: 2.5.4 eslint-scope@8.3.0: dependencies: @@ -5707,15 +6488,15 @@ snapshots: eslint-visitor-keys@4.2.0: {} - eslint@9.27.0(jiti@2.4.2): + eslint@9.28.0(jiti@2.4.2): dependencies: - '@eslint-community/eslint-utils': 4.7.0(eslint@9.27.0(jiti@2.4.2)) + '@eslint-community/eslint-utils': 4.7.0(eslint@9.28.0(jiti@2.4.2)) '@eslint-community/regexpp': 4.12.1 '@eslint/config-array': 0.20.0 '@eslint/config-helpers': 0.2.2 '@eslint/core': 0.14.0 '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.27.0 + '@eslint/js': 9.28.0 '@eslint/plugin-kit': 0.3.1 '@humanfs/node': 0.16.6 '@humanwhocodes/module-importer': 1.0.1 @@ -5789,13 +6570,13 @@ snapshots: signal-exit: 3.0.7 strip-eof: 1.0.0 - execa@9.5.3: + execa@9.6.0: dependencies: '@sindresorhus/merge-streams': 4.0.0 cross-spawn: 7.0.6 figures: 6.1.0 get-stream: 9.0.1 - human-signals: 8.0.0 + human-signals: 8.0.1 is-plain-obj: 4.1.0 is-stream: 4.0.1 npm-run-path: 6.0.0 @@ -5806,6 +6587,42 @@ snapshots: expect-type@1.2.1: {} + express@4.21.2: + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + express@5.1.0: dependencies: accepts: 2.0.0 @@ -5868,6 +6685,10 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-xml-parser@4.5.3: + dependencies: + strnum: 1.1.2 + fast-xml-parser@5.2.3: dependencies: strnum: 2.1.0 @@ -5884,6 +6705,10 @@ snapshots: optionalDependencies: picomatch: 4.0.2 + fdir@6.4.5(picomatch@4.0.2): + optionalDependencies: + picomatch: 4.0.2 + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -5896,6 +6721,18 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@1.3.1: + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + finalhandler@2.1.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -5930,6 +6767,8 @@ snapshots: forwarded@0.2.0: {} + fresh@0.5.2: {} + fresh@2.0.0: {} fs-extra@7.0.1: @@ -6037,7 +6876,7 @@ snapshots: graphemer@1.4.0: {} - graphql@16.10.0: {} + graphql@16.11.0: {} has-bigints@1.1.0: {} @@ -6073,7 +6912,7 @@ snapshots: headers-polyfill@4.0.3: {} - hono@4.7.10: {} + hono@4.7.11: {} http-errors@2.0.0: dependencies: @@ -6092,7 +6931,7 @@ snapshots: human-id@4.1.1: {} - human-signals@8.0.0: {} + human-signals@8.0.1: {} iconv-lite@0.4.24: dependencies: @@ -6370,23 +7209,37 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@0.3.0: {} + media-typer@1.1.0: {} + merge-descriptors@1.0.3: {} + merge-descriptors@2.0.0: {} merge2@1.4.1: {} + methods@1.1.2: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 + mime@1.6.0: {} + minimatch@3.1.2: dependencies: brace-expansion: 1.1.11 @@ -6403,20 +7256,22 @@ snapshots: mri@1.2.0: {} + ms@2.0.0: {} + ms@2.1.3: {} - msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3): + msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3): dependencies: '@bundled-es-modules/cookie': 2.0.1 '@bundled-es-modules/statuses': 1.0.1 '@bundled-es-modules/tough-cookie': 0.1.6 - '@inquirer/confirm': 5.1.7(@types/node@22.15.21) - '@mswjs/interceptors': 0.37.6 + '@inquirer/confirm': 5.1.12(@types/node@22.15.29) + '@mswjs/interceptors': 0.38.7 '@open-draft/deferred-promise': 2.2.0 '@open-draft/until': 2.1.0 '@types/cookie': 0.6.0 '@types/statuses': 2.0.5 - graphql: 16.10.0 + graphql: 16.11.0 headers-polyfill: 4.0.3 is-node-process: 1.2.0 outvariant: 1.4.3 @@ -6442,6 +7297,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@0.6.3: {} + negotiator@1.0.0: {} nice-napi@1.0.2: @@ -6632,6 +7489,8 @@ snapshots: path-parse@1.0.7: {} + path-to-regexp@0.1.12: {} + path-to-regexp@6.3.0: {} path-to-regexp@8.2.0: {} @@ -6662,15 +7521,15 @@ snapshots: possible-typed-array-names@1.1.0: {} - postcss-less@6.0.0(postcss@8.5.3): + postcss-less@6.0.0(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 - postcss-scss@4.0.9(postcss@8.5.3): + postcss-scss@4.0.9(postcss@8.5.4): dependencies: - postcss: 8.5.3 + postcss: 8.5.4 - postcss@8.5.3: + postcss@8.5.4: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -6678,28 +7537,28 @@ snapshots: prelude-ls@1.2.1: {} - prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3): + prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3): dependencies: - css-declaration-sorter: 7.2.0(postcss@8.5.3) - postcss-less: 6.0.0(postcss@8.5.3) - postcss-scss: 4.0.9(postcss@8.5.3) + css-declaration-sorter: 7.2.0(postcss@8.5.4) + postcss-less: 6.0.0(postcss@8.5.4) + postcss-scss: 4.0.9(postcss@8.5.4) prettier: 3.5.3 transitivePeerDependencies: - postcss - prettier-plugin-packagejson@2.5.14(prettier@3.5.3): + prettier-plugin-packagejson@2.5.15(prettier@3.5.3): dependencies: sort-package-json: 3.2.1 - synckit: 0.11.6 + synckit: 0.11.8 optionalDependencies: prettier: 3.5.3 - prettier-plugin-tailwindcss@0.6.11(@ianvs/prettier-plugin-sort-imports@4.4.1(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.3)(prettier@3.5.3))(prettier@3.5.3): + prettier-plugin-tailwindcss@0.6.12(@ianvs/prettier-plugin-sort-imports@4.4.2(prettier@3.5.3))(prettier-plugin-css-order@2.1.2(postcss@8.5.4)(prettier@3.5.3))(prettier@3.5.3): dependencies: prettier: 3.5.3 optionalDependencies: - '@ianvs/prettier-plugin-sort-imports': 4.4.1(prettier@3.5.3) - prettier-plugin-css-order: 2.1.2(postcss@8.5.3)(prettier@3.5.3) + '@ianvs/prettier-plugin-sort-imports': 4.4.2(prettier@3.5.3) + prettier-plugin-css-order: 2.1.2(postcss@8.5.4)(prettier@3.5.3) prettier@2.8.8: {} @@ -6735,6 +7594,10 @@ snapshots: punycode@2.3.1: {} + qs@6.13.0: + dependencies: + side-channel: 1.1.0 + qs@6.14.0: dependencies: side-channel: 1.1.0 @@ -6747,6 +7610,13 @@ snapshots: range-parser@1.2.1: {} + raw-body@2.5.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + raw-body@3.0.0: dependencies: bytes: 3.1.2 @@ -6842,11 +7712,11 @@ snapshots: optionalDependencies: '@babel/code-frame': 7.26.2 - rollup-plugin-esbuild@6.2.1(esbuild@0.25.4)(rollup@4.41.1): + rollup-plugin-esbuild@6.2.1(esbuild@0.25.5)(rollup@4.41.1): dependencies: debug: 4.4.0(supports-color@5.5.0) es-module-lexer: 1.6.0 - esbuild: 0.25.4 + esbuild: 0.25.5 get-tsconfig: 4.10.0 rollup: 4.41.1 unplugin-utils: 0.2.4 @@ -6936,6 +7806,24 @@ snapshots: semver@7.7.2: {} + send@0.19.0: + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + send@1.2.0: dependencies: debug: 4.4.1(supports-color@10.0.0) @@ -6952,6 +7840,15 @@ snapshots: transitivePeerDependencies: - supports-color + serve-static@1.16.2: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + serve-static@2.2.0: dependencies: encodeurl: 2.0.0 @@ -7069,7 +7966,7 @@ snapshots: is-plain-obj: 4.1.0 semver: 7.7.2 sort-object-keys: 1.1.3 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 source-map-js@1.2.1: {} @@ -7154,6 +8051,8 @@ snapshots: strip-json-comments@3.1.1: {} + strnum@1.1.2: {} + strnum@2.1.0: {} supports-color@10.0.0: {} @@ -7168,9 +8067,9 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - synckit@0.11.6: + synckit@0.11.8: dependencies: - '@pkgr/core': 0.2.4 + '@pkgr/core': 0.2.5 term-size@2.2.1: {} @@ -7183,8 +8082,6 @@ snapshots: tinybench@2.9.0: {} - tinybench@4.0.1: {} - tinyexec@0.3.2: {} tinyglobby@0.2.13: @@ -7192,6 +8089,11 @@ snapshots: fdir: 6.4.4(picomatch@4.0.2) picomatch: 4.0.2 + tinyglobby@0.2.14: + dependencies: + fdir: 6.4.5(picomatch@4.0.2) + picomatch: 4.0.2 + tinypool@1.0.2: {} tinyrainbow@2.0.0: {} @@ -7225,14 +8127,14 @@ snapshots: dependencies: typescript: 5.8.3 - ts-node@10.9.2(@types/node@22.15.21)(typescript@5.8.3): + ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 '@tsconfig/node12': 1.0.11 '@tsconfig/node14': 1.0.3 '@tsconfig/node16': 1.0.4 - '@types/node': 22.15.21 + '@types/node': 22.15.29 acorn: 8.14.1 acorn-walk: 8.3.4 arg: 4.1.3 @@ -7260,32 +8162,32 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo-darwin-64@2.5.3: + turbo-darwin-64@2.5.4: optional: true - turbo-darwin-arm64@2.5.3: + turbo-darwin-arm64@2.5.4: optional: true - turbo-linux-64@2.5.3: + turbo-linux-64@2.5.4: optional: true - turbo-linux-arm64@2.5.3: + turbo-linux-arm64@2.5.4: optional: true - turbo-windows-64@2.5.3: + turbo-windows-64@2.5.4: optional: true - turbo-windows-arm64@2.5.3: + turbo-windows-arm64@2.5.4: optional: true - turbo@2.5.3: + turbo@2.5.4: optionalDependencies: - turbo-darwin-64: 2.5.3 - turbo-darwin-arm64: 2.5.3 - turbo-linux-64: 2.5.3 - turbo-linux-arm64: 2.5.3 - turbo-windows-64: 2.5.3 - turbo-windows-arm64: 2.5.3 + turbo-darwin-64: 2.5.4 + turbo-darwin-arm64: 2.5.4 + turbo-linux-64: 2.5.4 + turbo-linux-arm64: 2.5.4 + turbo-windows-64: 2.5.4 + turbo-windows-arm64: 2.5.4 txml@5.1.1: dependencies: @@ -7301,6 +8203,11 @@ snapshots: type-fest@4.41.0: {} + type-is@1.6.18: + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + type-is@2.0.1: dependencies: content-type: 1.0.5 @@ -7340,12 +8247,12 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typescript-eslint@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3): + typescript-eslint@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3): dependencies: - '@typescript-eslint/eslint-plugin': 8.32.1(@typescript-eslint/parser@8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/parser': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - '@typescript-eslint/utils': 8.32.1(eslint@9.27.0(jiti@2.4.2))(typescript@5.8.3) - eslint: 9.27.0(jiti@2.4.2) + '@typescript-eslint/eslint-plugin': 8.33.0(@typescript-eslint/parser@8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3))(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/parser': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + '@typescript-eslint/utils': 8.33.0(eslint@9.28.0(jiti@2.4.2))(typescript@5.8.3) + eslint: 9.28.0(jiti@2.4.2) typescript: 5.8.3 transitivePeerDependencies: - supports-color @@ -7395,21 +8302,31 @@ snapshots: util-deprecate@1.0.2: {} + utils-merge@1.0.1: {} + v8-compile-cache-lib@3.0.1: {} + valibot@1.0.0-beta.12(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + + valibot@1.0.0-beta.9(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + valibot@1.1.0(typescript@5.8.3): optionalDependencies: typescript: 5.8.3 vary@1.1.2: {} - vite-node@3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4): + vite-node@3.1.4(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4): dependencies: cac: 6.7.14 debug: 4.4.1(supports-color@10.0.0) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - '@types/node' - jiti @@ -7424,35 +8341,44 @@ snapshots: - tsx - yaml - vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)): + vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)): dependencies: debug: 4.4.0(supports-color@5.5.0) globrex: 0.1.2 tsconfck: 3.1.5(typescript@5.8.3) optionalDependencies: - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) transitivePeerDependencies: - supports-color - typescript - vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4): + vite@5.4.19(@types/node@22.15.29): dependencies: - esbuild: 0.25.4 - fdir: 6.4.4(picomatch@4.0.2) + esbuild: 0.21.5 + postcss: 8.5.4 + rollup: 4.41.1 + optionalDependencies: + '@types/node': 22.15.29 + fsevents: 2.3.3 + + vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4): + dependencies: + esbuild: 0.25.5 + fdir: 6.4.5(picomatch@4.0.2) picomatch: 4.0.2 - postcss: 8.5.3 + postcss: 8.5.4 rollup: 4.41.1 - tinyglobby: 0.2.13 + tinyglobby: 0.2.14 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 fsevents: 2.3.3 jiti: 2.4.2 tsx: 4.19.4 - vitest@3.1.4(@types/node@22.15.21)(jiti@2.4.2)(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(tsx@4.19.4): + vitest@3.1.4(@types/node@22.15.29)(jiti@2.4.2)(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(tsx@4.19.4): dependencies: '@vitest/expect': 3.1.4 - '@vitest/mocker': 3.1.4(msw@2.8.4(@types/node@22.15.21)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4)) + '@vitest/mocker': 3.1.4(msw@2.8.7(@types/node@22.15.29)(typescript@5.8.3))(vite@6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4)) '@vitest/pretty-format': 3.1.4 '@vitest/runner': 3.1.4 '@vitest/snapshot': 3.1.4 @@ -7469,11 +8395,11 @@ snapshots: tinyglobby: 0.2.13 tinypool: 1.0.2 tinyrainbow: 2.0.0 - vite: 6.3.5(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) - vite-node: 3.1.4(@types/node@22.15.21)(jiti@2.4.2)(tsx@4.19.4) + vite: 6.3.5(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) + vite-node: 3.1.4(@types/node@22.15.29)(jiti@2.4.2)(tsx@4.19.4) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 22.15.21 + '@types/node': 22.15.29 transitivePeerDependencies: - jiti - less @@ -7605,4 +8531,4 @@ snapshots: toposort: 2.0.2 type-fest: 2.19.0 - zod@3.25.28: {} + zod@3.25.46: {}