From 873b9b218d4a2f4b7e71c7ce659d625141c18d70 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Fri, 24 Jul 2026 18:04:21 +0200
Subject: [PATCH] feat: publish Regex Tools 0.1.0
---
.gitignore | 12 +
.npmrc | 2 +
CHANGELOG.md | 12 +
LICENSE | 232 +
LICENSES/CodeMirror-MIT.txt | 21 +
LICENSES/Lezer-MIT.txt | 21 +
LICENSES/React-MIT.txt | 21 +
LICENSES/fflate-MIT.txt | 21 +
LICENSES/regexpp-MIT.txt | 21 +
LICENSES/toolbox-sdk-Apache-2.0.txt | 192 +
README.md | 242 +
SOURCE.md | 23 +
THIRD_PARTY_NOTICES.md | 29 +
docs/ARCHITECTURE.md | 41 +
docs/ENGINE_BUILDS.md | 28 +
docs/EXECUTION_TRACES.md | 13 +
docs/EXPLANATION_MODEL.md | 20 +
docs/EXTRACTION_MODEL.md | 27 +
docs/FLAVOUR_SUPPORT.md | 20 +
docs/PARSER_PROFILES.md | 16 +
docs/PERFORMANCE.md | 66 +
docs/PORTAL_REQUIREMENTS.md | 19 +
docs/REFERENCE_IMPLEMENTATIONS.md | 21 +
docs/RELEASE.md | 23 +
docs/SECURITY.md | 51 +
eslint.config.mjs | 43 +
index.html | 17 +
package-lock.json | 4166 +++++++++++++++++
package.json | 81 +
playwright.config.ts | 25 +
public/engines/README.md | 7 +
public/favicon.svg | 7 +
public/toolbox-app.json | 40 +
scripts/build-engines.mjs | 24 +
scripts/checksum-release.mjs | 12 +
scripts/generate-toolbox-manifest.mjs | 42 +
scripts/package-release.mjs | 526 +++
scripts/package-release.test.ts | 51 +
scripts/serve-test.mjs | 79 +
scripts/verify-engine-assets.mjs | 34 +
src/App.tsx | 42 +
src/app/AppErrorBoundary.tsx | 37 +
src/browser/clipboard.test.ts | 52 +
src/browser/clipboard.ts | 34 +
src/components/CapabilityPanel.test.tsx | 88 +
src/components/CapabilityPanel.tsx | 122 +
src/components/CaptureTable.test.tsx | 236 +
src/components/CaptureTable.tsx | 551 +++
src/components/DiagnosticsPanel.tsx | 42 +
src/components/ExplanationTree.test.tsx | 94 +
src/components/ExplanationTree.tsx | 229 +
src/components/ExtractionTree.test.tsx | 76 +
src/components/ExtractionTree.tsx | 223 +
src/components/HelpDialog.tsx | 151 +
src/components/ListPanel.tsx | 226 +
src/components/ProjectPanel.tsx | 192 +
src/components/QuickReference.test.tsx | 26 +
src/components/QuickReference.tsx | 94 +
.../ReplacementPanel.completeness.test.tsx | 89 +
src/components/ReplacementPanel.tsx | 284 ++
.../ReplacementPreviewPanel.test.tsx | 72 +
src/components/ReplacementPreviewPanel.tsx | 166 +
src/components/TemplateLimits.test.tsx | 172 +
src/components/UnitTestPanel.test.tsx | 573 +++
src/components/UnitTestPanel.tsx | 1106 +++++
src/components/Workbench.test.tsx | 383 ++
src/components/Workbench.tsx | 1809 +++++++
src/components/tree-navigation.ts | 65 +
src/editors/CodeEditor.tsx | 320 ++
src/editors/editor.types.ts | 9 +
src/main.tsx | 9 +
src/project/persistence.test.ts | 147 +
src/project/persistence.ts | 97 +
src/project/project-limits.test.ts | 42 +
src/project/project-limits.ts | 219 +
src/project/project.serialization.ts | 50 +
src/project/project.test.ts | 246 +
src/project/project.types.ts | 37 +
src/project/project.validation.ts | 398 ++
src/regex/execution/EngineSupervisor.ts | 73 +
src/regex/execution/RegexEngineAdapter.ts | 15 +
src/regex/execution/SyntaxSupervisor.ts | 71 +
src/regex/execution/WorkerSupervisor.test.ts | 125 +
src/regex/execution/WorkerSupervisor.ts | 223 +
.../EcmaScriptEngineAdapter.test.ts | 412 ++
.../ecmascript/EcmaScriptEngineAdapter.ts | 556 +++
.../execution/offsets/codepoint-to-utf16.ts | 49 +
.../execution/offsets/line-offset-map.test.ts | 32 +
.../execution/offsets/line-offset-map.ts | 49 +
.../execution/offsets/offset-map.test.ts | 66 +
src/regex/execution/offsets/offset-map.ts | 57 +
src/regex/execution/offsets/utf8-to-utf16.ts | 62 +
src/regex/execution/request-limits.test.ts | 12 +
src/regex/execution/request-limits.ts | 84 +
src/regex/execution/worker-protocol.ts | 89 +
src/regex/extraction/build-extraction-tree.ts | 121 +
src/regex/extraction/capture-rows.ts | 63 +
src/regex/extraction/extraction.test.ts | 63 +
src/regex/list/list-template.test.ts | 257 +
src/regex/list/list-template.ts | 286 ++
src/regex/model/diagnostics.ts | 14 +
src/regex/model/flavour.ts | 45 +
src/regex/model/match.ts | 99 +
src/regex/model/syntax.ts | 157 +
src/regex/reference/token-reference.ts | 142 +
.../replacement/replacement-preview.test.ts | 342 ++
src/regex/replacement/replacement-preview.ts | 303 ++
src/regex/syntax/SyntaxProvider.ts | 25 +
.../syntax/pattern-syntax-freshness.test.ts | 36 +
src/regex/syntax/pattern-syntax-freshness.ts | 31 +
.../EcmaScriptSyntaxProvider.test.ts | 280 ++
.../ecmascript/EcmaScriptSyntaxProvider.ts | 119 +
.../syntax/providers/ecmascript/normalize.ts | 678 +++
.../providers/ecmascript/replacement.ts | 247 +
src/regex/tests/current-result-draft.test.ts | 55 +
src/regex/tests/current-result-draft.ts | 45 +
src/regex/tests/test-case.types.ts | 57 +
src/regex/tests/test-runner.test.ts | 371 ++
src/regex/tests/test-runner.ts | 269 ++
.../tests/test-suite.serialization.test.ts | 88 +
src/regex/tests/test-suite.serialization.ts | 108 +
src/styles.css | 1497 ++++++
src/test/setup.ts | 5 +
src/toolbox/manifest.source.json | 40 +
src/toolbox/manifest.ts | 4 +
src/types/scripts.d.ts | 13 +
src/version.ts | 3 +
src/workers/ecmascript.worker.ts | 51 +
src/workers/syntax.worker.ts | 49 +
tests/browser/workbench.spec.ts | 781 +++
.../ecmascript.conformance.test.ts | 248 +
tests/fixtures/README.md | 13 +
tsconfig.app.json | 27 +
tsconfig.json | 7 +
tsconfig.node.json | 24 +
vite.config.ts | 18 +
136 files changed, 24212 insertions(+)
create mode 100644 .gitignore
create mode 100644 .npmrc
create mode 100644 CHANGELOG.md
create mode 100644 LICENSE
create mode 100644 LICENSES/CodeMirror-MIT.txt
create mode 100644 LICENSES/Lezer-MIT.txt
create mode 100644 LICENSES/React-MIT.txt
create mode 100644 LICENSES/fflate-MIT.txt
create mode 100644 LICENSES/regexpp-MIT.txt
create mode 100644 LICENSES/toolbox-sdk-Apache-2.0.txt
create mode 100644 README.md
create mode 100644 SOURCE.md
create mode 100644 THIRD_PARTY_NOTICES.md
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/ENGINE_BUILDS.md
create mode 100644 docs/EXECUTION_TRACES.md
create mode 100644 docs/EXPLANATION_MODEL.md
create mode 100644 docs/EXTRACTION_MODEL.md
create mode 100644 docs/FLAVOUR_SUPPORT.md
create mode 100644 docs/PARSER_PROFILES.md
create mode 100644 docs/PERFORMANCE.md
create mode 100644 docs/PORTAL_REQUIREMENTS.md
create mode 100644 docs/REFERENCE_IMPLEMENTATIONS.md
create mode 100644 docs/RELEASE.md
create mode 100644 docs/SECURITY.md
create mode 100644 eslint.config.mjs
create mode 100644 index.html
create mode 100644 package-lock.json
create mode 100644 package.json
create mode 100644 playwright.config.ts
create mode 100644 public/engines/README.md
create mode 100644 public/favicon.svg
create mode 100644 public/toolbox-app.json
create mode 100644 scripts/build-engines.mjs
create mode 100644 scripts/checksum-release.mjs
create mode 100644 scripts/generate-toolbox-manifest.mjs
create mode 100644 scripts/package-release.mjs
create mode 100644 scripts/package-release.test.ts
create mode 100644 scripts/serve-test.mjs
create mode 100644 scripts/verify-engine-assets.mjs
create mode 100644 src/App.tsx
create mode 100644 src/app/AppErrorBoundary.tsx
create mode 100644 src/browser/clipboard.test.ts
create mode 100644 src/browser/clipboard.ts
create mode 100644 src/components/CapabilityPanel.test.tsx
create mode 100644 src/components/CapabilityPanel.tsx
create mode 100644 src/components/CaptureTable.test.tsx
create mode 100644 src/components/CaptureTable.tsx
create mode 100644 src/components/DiagnosticsPanel.tsx
create mode 100644 src/components/ExplanationTree.test.tsx
create mode 100644 src/components/ExplanationTree.tsx
create mode 100644 src/components/ExtractionTree.test.tsx
create mode 100644 src/components/ExtractionTree.tsx
create mode 100644 src/components/HelpDialog.tsx
create mode 100644 src/components/ListPanel.tsx
create mode 100644 src/components/ProjectPanel.tsx
create mode 100644 src/components/QuickReference.test.tsx
create mode 100644 src/components/QuickReference.tsx
create mode 100644 src/components/ReplacementPanel.completeness.test.tsx
create mode 100644 src/components/ReplacementPanel.tsx
create mode 100644 src/components/ReplacementPreviewPanel.test.tsx
create mode 100644 src/components/ReplacementPreviewPanel.tsx
create mode 100644 src/components/TemplateLimits.test.tsx
create mode 100644 src/components/UnitTestPanel.test.tsx
create mode 100644 src/components/UnitTestPanel.tsx
create mode 100644 src/components/Workbench.test.tsx
create mode 100644 src/components/Workbench.tsx
create mode 100644 src/components/tree-navigation.ts
create mode 100644 src/editors/CodeEditor.tsx
create mode 100644 src/editors/editor.types.ts
create mode 100644 src/main.tsx
create mode 100644 src/project/persistence.test.ts
create mode 100644 src/project/persistence.ts
create mode 100644 src/project/project-limits.test.ts
create mode 100644 src/project/project-limits.ts
create mode 100644 src/project/project.serialization.ts
create mode 100644 src/project/project.test.ts
create mode 100644 src/project/project.types.ts
create mode 100644 src/project/project.validation.ts
create mode 100644 src/regex/execution/EngineSupervisor.ts
create mode 100644 src/regex/execution/RegexEngineAdapter.ts
create mode 100644 src/regex/execution/SyntaxSupervisor.ts
create mode 100644 src/regex/execution/WorkerSupervisor.test.ts
create mode 100644 src/regex/execution/WorkerSupervisor.ts
create mode 100644 src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.test.ts
create mode 100644 src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.ts
create mode 100644 src/regex/execution/offsets/codepoint-to-utf16.ts
create mode 100644 src/regex/execution/offsets/line-offset-map.test.ts
create mode 100644 src/regex/execution/offsets/line-offset-map.ts
create mode 100644 src/regex/execution/offsets/offset-map.test.ts
create mode 100644 src/regex/execution/offsets/offset-map.ts
create mode 100644 src/regex/execution/offsets/utf8-to-utf16.ts
create mode 100644 src/regex/execution/request-limits.test.ts
create mode 100644 src/regex/execution/request-limits.ts
create mode 100644 src/regex/execution/worker-protocol.ts
create mode 100644 src/regex/extraction/build-extraction-tree.ts
create mode 100644 src/regex/extraction/capture-rows.ts
create mode 100644 src/regex/extraction/extraction.test.ts
create mode 100644 src/regex/list/list-template.test.ts
create mode 100644 src/regex/list/list-template.ts
create mode 100644 src/regex/model/diagnostics.ts
create mode 100644 src/regex/model/flavour.ts
create mode 100644 src/regex/model/match.ts
create mode 100644 src/regex/model/syntax.ts
create mode 100644 src/regex/reference/token-reference.ts
create mode 100644 src/regex/replacement/replacement-preview.test.ts
create mode 100644 src/regex/replacement/replacement-preview.ts
create mode 100644 src/regex/syntax/SyntaxProvider.ts
create mode 100644 src/regex/syntax/pattern-syntax-freshness.test.ts
create mode 100644 src/regex/syntax/pattern-syntax-freshness.ts
create mode 100644 src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.test.ts
create mode 100644 src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.ts
create mode 100644 src/regex/syntax/providers/ecmascript/normalize.ts
create mode 100644 src/regex/syntax/providers/ecmascript/replacement.ts
create mode 100644 src/regex/tests/current-result-draft.test.ts
create mode 100644 src/regex/tests/current-result-draft.ts
create mode 100644 src/regex/tests/test-case.types.ts
create mode 100644 src/regex/tests/test-runner.test.ts
create mode 100644 src/regex/tests/test-runner.ts
create mode 100644 src/regex/tests/test-suite.serialization.test.ts
create mode 100644 src/regex/tests/test-suite.serialization.ts
create mode 100644 src/styles.css
create mode 100644 src/test/setup.ts
create mode 100644 src/toolbox/manifest.source.json
create mode 100644 src/toolbox/manifest.ts
create mode 100644 src/types/scripts.d.ts
create mode 100644 src/version.ts
create mode 100644 src/workers/ecmascript.worker.ts
create mode 100644 src/workers/syntax.worker.ts
create mode 100644 tests/browser/workbench.spec.ts
create mode 100644 tests/conformance/ecmascript.conformance.test.ts
create mode 100644 tests/fixtures/README.md
create mode 100644 tsconfig.app.json
create mode 100644 tsconfig.json
create mode 100644 tsconfig.node.json
create mode 100644 vite.config.ts
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..b88b9b1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,12 @@
+node_modules/
+dist/
+release/
+coverage/
+playwright-report/
+test-results/
+*.tsbuildinfo
+.DS_Store
+.env
+.env.*
+!.env.example
+
diff --git a/.npmrc b/.npmrc
new file mode 100644
index 0000000..56724c0
--- /dev/null
+++ b/.npmrc
@@ -0,0 +1,2 @@
+@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
+
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..a468414
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,12 @@
+# Changelog
+
+## 0.1.0 — 2026-07-24
+
+- Added the open-source ECMAScript 2025 syntax provider using regexpp 4.12.2.
+- Added native browser `RegExp` execution in a killable, recoverable worker.
+- Added deterministic explanation and actual extraction trees.
+- Added exact capture indices, synchronized highlighting and bounded captures.
+- Added replacement, list/extraction and unit-test workspaces.
+- Added project import/export, optional local persistence and privacy controls.
+- Added resource limits, real timeout termination and recovery tests.
+- Added Toolbox SDK integration and deterministic release packaging.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..f6cdd22
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,232 @@
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
+
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and other kinds of works.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+TERMS AND CONDITIONS
+
+0. Definitions.
+
+“This License” refers to version 3 of the GNU General Public License.
+
+“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
+
+To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
+
+A “covered work” means either the unmodified Program or a work based on the Program.
+
+To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
+
+A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+2. Basic Permissions.
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+4. Conveying Verbatim Copies.
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
+
+ c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+7. Additional Terms.
+“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
+
+8. Termination.
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+11. Patents.
+A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
+
+A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+13. Use with the GNU Affero General Public License.
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
+
+14. Revised Versions of this License.
+The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+15. Disclaimer of Warranty.
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
+
+You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .
diff --git a/LICENSES/CodeMirror-MIT.txt b/LICENSES/CodeMirror-MIT.txt
new file mode 100644
index 0000000..9a91f48
--- /dev/null
+++ b/LICENSES/CodeMirror-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (C) 2018-2021 by Marijn Haverbeke and others
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/LICENSES/Lezer-MIT.txt b/LICENSES/Lezer-MIT.txt
new file mode 100644
index 0000000..c21df7e
--- /dev/null
+++ b/LICENSES/Lezer-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (C) 2018 by Marijn Haverbeke and others
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
diff --git a/LICENSES/React-MIT.txt b/LICENSES/React-MIT.txt
new file mode 100644
index 0000000..b93be90
--- /dev/null
+++ b/LICENSES/React-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) Meta Platforms, Inc. and affiliates.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSES/fflate-MIT.txt b/LICENSES/fflate-MIT.txt
new file mode 100644
index 0000000..33ba32d
--- /dev/null
+++ b/LICENSES/fflate-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Arjun Barrett
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSES/regexpp-MIT.txt b/LICENSES/regexpp-MIT.txt
new file mode 100644
index 0000000..883ee1f
--- /dev/null
+++ b/LICENSES/regexpp-MIT.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 Toru Nagashima
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/LICENSES/toolbox-sdk-Apache-2.0.txt b/LICENSES/toolbox-sdk-Apache-2.0.txt
new file mode 100644
index 0000000..c38585f
--- /dev/null
+++ b/LICENSES/toolbox-sdk-Apache-2.0.txt
@@ -0,0 +1,192 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ Copyright 2026 ADD Ideas
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..0274a00
--- /dev/null
+++ b/README.md
@@ -0,0 +1,242 @@
+# Regex Tools
+
+Regex Tools is a production-oriented, local-first browser workbench for
+developing, understanding, testing and applying regular expressions. It is an
+independent static React/Vite application and a module of add·ideas Toolbox.
+
+Patterns, replacement templates, test text and project files stay in the
+browser. There is no account, backend, upload, remote execution, telemetry,
+runtime CDN, remote font, or cloud-save service.
+
+## Current milestone
+
+Version 0.1.0 is the first production flavour slice: ECMAScript.
+
+- Syntax acceptance: `@eslint-community/regexpp` 4.12.2, ECMAScript 2025
+ grammar; application-owned structural explanations are partial and
+ feature-matrix tested.
+- Execution engine: the current browser's native ECMAScript `RegExp`.
+- Native and editor offsets: UTF-16 code units.
+- Parsing and execution run in separate workers.
+- Actual execution can be terminated by killing its worker.
+- The worker is recreated after timeout, crash or cancellation.
+- Capture ranges use the engine's `d` indices result.
+- Named, numbered, optional, empty and repeated-final captures are represented.
+- Match, replacement, typed list/extraction and unit-test modes are available.
+- Project JSON import/export and optional IndexedDB save are available.
+- Interactive test text is not persisted by default.
+
+No licensed parser is used, referenced at runtime, or required to build the
+project. This is the community build and will grow flavour by flavour using
+reviewed open-source syntax providers and actual named engines.
+
+## The two trees
+
+The **explanation tree** is a deterministic structural explanation generated
+from the application-owned normalized syntax tree. It is not an engine trace.
+
+The **extraction tree** contains actual engine matches and captures. Its
+hierarchy uses syntactic capture nesting while preserving actual engine ranges.
+ECMAScript exposes only the final retained capture of a repeated group; Regex
+Tools says so and never invents capture history.
+
+Browser ECMAScript engines do not expose their internal backtracking trace.
+Version 0.1.0 therefore presents no ECMAScript execution trace and no
+educational trace disguised as one.
+
+## Workbench modes
+
+- **Match** — live highlighting, extraction tree and bounded capture table.
+- **Replace** — native ECMAScript `RegExp` matches with bounded,
+ specification-compatible string substitution; output completeness is
+ reported separately from bounded per-match original/result, changed-range and
+ token-contribution previews that synchronize the replacement, pattern,
+ subject and output editors.
+- **List** — non-executable typed templates with text, CSV, JSON and NDJSON
+ export; export is disabled when engine collection or bounded rendering is
+ incomplete.
+- **Unit tests** — editable and cloneable versioned presence, count, full-match,
+ capture, replacement, timing and expected-timeout assertions. Capture a
+ complete current result, select only cases to rerun, filter failures, and
+ import/export a standalone validated JSON suite; execution stays in isolated
+ workers.
+
+Corpus, comparison, debugging, analysis, generation, minimization, formatting
+and code generation remain roadmap items. Controls for them are not exposed in
+this release.
+
+## Flags and iteration
+
+Pattern and flags are stored separately; delimiters are presentation only.
+Native `g` and `y` behavior is preserved. The optional **Scan all** action is
+explicit. When neither `g` nor `y` is present it adds `g` only to that execution
+request, visibly records the internal flag, and does not change the saved user
+flags. The `d` flag may likewise be added internally to obtain exact indices
+without changing matching semantics.
+
+Zero-length global iteration advances using the selected Unicode semantics and
+cannot loop forever.
+
+## Limits and timeout behavior
+
+Important defaults:
+
+| Limit | Default |
+| ------------------------------ | -----------------: |
+| Live parse debounce | 120 ms |
+| Live execution debounce | 220 ms |
+| Live execution timeout | 250 ms |
+| Manual execution timeout | 2 s |
+| Advanced maximum timeout | 10 s |
+| Pattern hard limit | 64 Ki UTF-16 units |
+| Interactive subject hard limit | 16 MiB |
+| Replacement template | 64 Ki UTF-16 units |
+| List template | 16 Ki UTF-16 units |
+| Per-match replacement preview | 200 matches |
+| Replacement contributions | 2,000 |
+| Maximum matches | 10,000 |
+| Maximum capture groups | 1,000 |
+| Maximum capture rows | 100,000 |
+| Replacement preview | 64 MiB |
+| Project JSON document | 32 MiB UTF-8 |
+| Standalone test-suite JSON | 32 MiB UTF-8 |
+| In-memory unit-test suite | 32 MiB UTF-8 |
+| Tests per suite | 1,000 |
+| Retained message per test | 2 KiB UTF-8 |
+| Unit-test suite wall time | 60 s |
+
+A timeout terminates actual execution. It is reported as **timed out**, never as
+“no match”. The next request creates a fresh worker.
+
+Replacement applies exactly the authoritative matches returned under the match
+and capture-row limits. If either collection limit is reached, later subject
+text remains unchanged and the output is explicitly reported as partial. The
+separate output-byte limit reports a truncated output preview.
+
+## Project persistence
+
+Project schema version 1 stores flavour, pattern, flags, options, replacement,
+list template, unit tests (including explicit scan-all behavior and supported
+per-test timeouts) and selected UI state. Import rebuilds a validated plain
+object; it never evaluates code and pauses live execution until review.
+Workbench-wide manual timeout changes are not persisted, and unsupported
+project resource overrides are rejected.
+
+Interactive test text is excluded from local save and export unless explicitly
+enabled. Unit-test definitions are deliberate project data, but their subjects
+require separate explicit opt-ins for JSON export and IndexedDB save.
+Standalone test-suite JSON likewise omits subjects by default, is schema- and
+field-validated on import, and appends at most 1,000 total tests. Add, update,
+clone and append-import also preflight the complete in-memory suite against a
+32 MiB aggregate bound. Corpus content is not supported or persisted in this
+release. Import, export and IndexedDB save enforce one 32 MiB aggregate UTF-8
+JSON-document limit in addition to per-field limits. The latest local save is
+read through an `updatedAt` index cursor; loading it does not materialize the
+complete project store.
+
+## Development
+
+Requirements: Node.js 22 or later and npm 11.
+
+```sh
+npm install
+npm run dev
+```
+
+Quality and release commands:
+
+```sh
+npm run typecheck
+npm run lint
+npm run format:check
+npm test
+npm run test:conformance
+npm run test:browser
+npm run engines:build
+npm run engines:verify
+npm run build
+npm run toolbox:check
+npm run check
+npm run release:artifact
+```
+
+The normal build performs no network download. `vite.config.ts` uses
+`base: "./"`; all workers and assets are self-hosted and nested-path safe.
+
+## Toolbox SDK and Portal
+
+The application uses Toolbox SDK 0.2.2 `AppShell`. Its canonical typed/static
+manifest source is `src/toolbox/manifest.source.json`; generation fails if its
+version or source identity drifts.
+
+The deterministic release ZIP can be consumed by Toolbox Portal using an exact
+artifact URL, manifest ID, version and SHA-256. Portal builds consume the
+artifact; they do not build Regex Tools source.
+
+Version 0.1.0 has no WebAssembly. Its minimum CSP needs self-hosted scripts and
+workers, not `wasm-unsafe-eval`. See
+[`docs/PORTAL_REQUIREMENTS.md`](docs/PORTAL_REQUIREMENTS.md).
+
+## Flavour roadmap
+
+The next flavour is official PCRE2 10.47 WebAssembly. A technical spike from the
+signed official tag has already demonstrated Unicode matching, named captures,
+substitution, automatic and explicit callouts, and match/depth/heap limits.
+PCRE2 is not shipped until the application-owned bridge, byte/UTF-16 offsets,
+trace caps, browser tests and source/licence material meet the acceptance gate.
+
+Python, Go, Rust, .NET, Java and legacy PCRE follow only through their actual
+named runtimes. No flavour is emulated through JavaScript and relabelled.
+
+## Licensing
+
+Regex Tools original source is GPL-3.0-or-later, copyright © 2026 Albrecht
+Degering. Shipped dependencies retain their own compatible licences and
+notices; see [`THIRD_PARTY_NOTICES.md`](THIRD_PARTY_NOTICES.md) and
+[`LICENSES/`](LICENSES/).
+
+No regex101 application source, branding, generated explanation prose,
+reference text or visual design is copied. RegexLib and RGXP.RU fixtures are not
+redistributed because a suitable fixture licence was not established. No
+RegexHub fixture is shipped in v0.1.0.
+
+## Known limitations
+
+- ECMAScript syntax coverage follows regexpp's ECMAScript 2025 grammar; a later
+ browser proposal may execute before the provider understands it.
+- Exact consumed-length bounds for ECMAScript Unicode properties of strings
+ remain unknown and are reported as such rather than guessed.
+- regexpp does not provide tolerant AST recovery. Malformed input receives an
+ exact reported error position as a zero-width range and an application-owned
+ error node, not a fabricated multi-character span or partial provider AST.
+- Native JavaScript compile errors do not consistently expose a machine-readable
+ source offset; the provider diagnostic supplies the range.
+- Capture history and actual ECMAScript execution tracing are unavailable.
+- Capture-table rendering is paged at 200 rows while aggregate collection is
+ bounded separately.
+- Explanation/extraction trees and editor decorations show bounded 2,000-node
+ previews with explicit totals; normalized results and table/export data stay
+ separate from those render-only caps.
+- Extraction-tree values show at most 100 UTF-16 units and capture-table cells
+ at most 512. UI-only shortening shows the exact range length; engine-clipped
+ values are labelled as prefixes of that range. Copy and eligible JSON export
+ continue to use the separately bounded engine value.
+- Replacement parsing is complete below its 64 Ki input limit, while editor
+ marks, token rows and diagnostics have explicit presentation caps. List token
+ chips are similarly bounded, and list rendering stops at 250,000 token
+ evaluations.
+- Per-match replacement presentation renders at most 200 matches and 2,000
+ token contributions under a 50,000 token-evaluation budget. Bounded
+ replacement output remains available separately and is labelled partial if
+ match collection reached a limit.
+- Result-value previews are capped per value and overall. List export is
+ disabled whenever a bounded preview would otherwise be mistaken for complete
+ data.
+- Unit-test failure messages retain at most 2 KiB UTF-8 per case, so 1,000
+ results stay below the 2 MiB log budget. Oversized exact replacement results
+ offer a lightweight `should-match` draft when one is valid and fits the suite.
+- Benchmark, risk analysis, generated cases, minimization and corpus processing
+ are not in this release.
+
+Architecture, security, performance, provenance and flavour details live in
+[`docs/`](docs/).
diff --git a/SOURCE.md b/SOURCE.md
new file mode 100644
index 0000000..4be6470
--- /dev/null
+++ b/SOURCE.md
@@ -0,0 +1,23 @@
+# Source identity
+
+- Project: Regex Tools
+- Version: 0.1.0
+- Repository:
+- Release tag: `v0.1.0`
+- Licence: GPL-3.0-or-later
+- Copyright: © 2026 Albrecht Degering
+
+The release ZIP is generated from the tagged repository with:
+
+```sh
+npm ci
+npm run release:artifact
+```
+
+The build uses only pinned npm packages from `package-lock.json`. It performs no
+runtime or build-time CDN fetch. Complete preferred-form source is the tagged
+repository. The release archive contains compiled static assets plus legal,
+source-identity and attribution documents.
+
+No PCRE2, Python, Go, Rust, .NET, Java or legacy-PCRE runtime is included in
+version 0.1.0.
diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md
new file mode 100644
index 0000000..c8a8f39
--- /dev/null
+++ b/THIRD_PARTY_NOTICES.md
@@ -0,0 +1,29 @@
+# Third-party notices
+
+Regex Tools original source is GPL-3.0-or-later. The production browser bundle
+also contains the compatible components below. Exact dependency resolution is
+recorded in `package-lock.json`.
+
+| Component | Version | Licence | Shipped | Role and source |
+| -------------------------------- | ------------------------------------------------------------------------ | ---------- | ----------------- | ------------------------------------------------------------------------ |
+| `@add-ideas/toolbox-contract` | 0.2.2 | Apache-2.0 | Runtime | Manifest contract; |
+| `@add-ideas/toolbox-shell-react` | 0.2.2 | Apache-2.0 | Runtime | Shared shell; same source |
+| `@eslint-community/regexpp` | 4.12.2 | MIT | Syntax worker | ECMAScript parser; |
+| CodeMirror packages | state 6.7.1; view 6.43.6; language 6.12.4; commands 6.10.4; search 6.7.1 | MIT | Runtime | Editors; |
+| `@lezer/highlight` | 1.2.3 | MIT | Runtime | Editor highlighting support; |
+| React / React DOM | 19.2.7 | MIT | Runtime | User interface; |
+| `fflate` | 0.8.3 | MIT | Packaging utility | Deterministic release ZIP; |
+| Vite | 8.1.5 | MIT | Generated helpers | Production build; |
+| Rolldown | 1.1.5 | MIT | Generated helpers | Production bundling; |
+
+`@add-ideas/toolbox-testkit` 0.2.2 and the other test/build dependencies are
+development-only and are not part of the static runtime bundle.
+
+`recheck` 4.5.0 and `regexp-ast-analysis` 0.7.1 were inspected but deliberately
+not installed or shipped. PCRE2 10.47 was used only in an external feasibility
+spike and is not present in the v0.1.0 artifact.
+
+Corresponding licence texts and copyright notices are in `LICENSES/`. The
+release package additionally carries the exact Vite and Rolldown legal files
+under `LICENSES/build/`, including Vite's bundled-dependency notices for code
+emitted by the production build.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..3d49116
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,41 @@
+# Architecture
+
+Regex Tools is a static browser application with framework-neutral regex
+boundaries.
+
+```text
+React workbench
+ ├─ Pattern SyntaxSupervisor → syntax.worker → Regexpp provider → normalized AST
+ ├─ Replacement SyntaxSupervisor → syntax.worker → typed replacement tokens
+ ├─ EngineSupervisor → ecmascript.worker → native RegExp → match DTOs
+ ├─ Test SyntaxSupervisor + EngineSupervisor → isolated cancellable test runs
+ └─ project validator / serializer / IndexedDB persistence
+```
+
+Provider AST objects never cross the worker boundary. The syntax worker emits
+application-owned nodes, tokens, diagnostics, capture definitions and explicit
+provenance. React never imports or traverses regexpp's cyclic AST.
+
+Every engine request carries a protocol version, monotonically increasing
+request ID and worker generation. A supervisor allows one active request,
+rejects stale responses, terminates on timeout/crash/cancel, and lazily creates
+a new generation. Timeout is a distinct error state.
+
+Pattern and replacement syntax use independent supervisors. A pattern snapshot
+is bound to its exact pattern, ordered flags and revision; execution cannot use
+acceptance or capture metadata from an earlier input. Replacement output is
+built while the native engine produces the same bounded authoritative match
+sequence that is returned to the UI.
+
+Per-match replacement ranges and token contributions are deterministic mappings
+over those actual matches, the complete replacement-token model and the bounded
+output. They execute no replacement callback or user code and have separate
+presentation and token-evaluation limits.
+
+Native ranges are retained in the engine's declared unit. Browser editor ranges
+are half-open UTF-16 code-unit ranges. Reusable converters cover UTF-8 byte and
+Unicode code-point offsets for later flavours, including invalid-boundary
+rejection and lone-surrogate detection.
+
+The release boundary is `dist/` plus checked-in legal/source documents. The
+Portal consumes the resulting immutable ZIP and never imports React source.
diff --git a/docs/ENGINE_BUILDS.md b/docs/ENGINE_BUILDS.md
new file mode 100644
index 0000000..ec068a7
--- /dev/null
+++ b/docs/ENGINE_BUILDS.md
@@ -0,0 +1,28 @@
+# Engine builds
+
+Version 0.1.0 ships no WebAssembly engine.
+
+`npm run engines:build` verifies that this release has no external pack to
+build. After the production build, `npm run engines:verify` rejects any
+undeclared engine asset and confirms the packaged ECMAScript-only notice.
+Neither command accesses the network.
+
+## PCRE2 feasibility record
+
+The next flavour was spiked outside the application repository against official
+PCRE2 10.47:
+
+- signed tag object `cd007b4466798f66d479d1442a407099e7c40050`;
+- peeled commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429`;
+- licence `BSD-3-Clause WITH PCRE2-exception`;
+- Emscripten 6.0.4;
+- 8-bit library, Unicode enabled, JIT disabled.
+
+Node and Chromium tests demonstrated version/config reporting, Unicode,
+named groups, global substitution, automatic and explicit callouts, and
+match/depth/heap errors. This was a technical spike only. No spike source,
+binary or PCRE2 source is included in v0.1.0.
+
+Production PCRE2 support remains gated on a reviewed bridge, copied DTOs,
+allocation cleanup, exact source build, UTF-8/UTF-16 mapping, trace/output caps,
+worker recovery, deterministic assets, licences and browser conformance.
diff --git a/docs/EXECUTION_TRACES.md b/docs/EXECUTION_TRACES.md
new file mode 100644
index 0000000..236ac8f
--- /dev/null
+++ b/docs/EXECUTION_TRACES.md
@@ -0,0 +1,13 @@
+# Execution traces
+
+Version 0.1.0 exposes no execution trace.
+
+Browser ECMAScript APIs return compilation, match, capture and replacement
+results but not the runtime's internal backtracking events. Regex Tools does
+not label structural explanations, static hints or adjacent-result inference
+as an actual engine trace.
+
+The next PCRE2 slice will use actual automatic/explicit callout events from an
+application-owned bridge. Event count, serialized bytes, match/depth/heap
+limits and wall-clock termination must all be enforced. Classifications such
+as “apparent backtrack” will remain visibly derived from adjacent events.
diff --git a/docs/EXPLANATION_MODEL.md b/docs/EXPLANATION_MODEL.md
new file mode 100644
index 0000000..37f2e16
--- /dev/null
+++ b/docs/EXPLANATION_MODEL.md
@@ -0,0 +1,20 @@
+# Explanation model
+
+The community provider converts regexpp nodes into a stable application AST
+inside the syntax worker. Nodes carry deterministic IDs, UTF-16 ranges, raw
+source, capture metadata, quantifier bounds, assertion kind, support status,
+provider identity and provenance.
+
+Explanations are original deterministic text selected by node type. Nullable
+and minimum/maximum consumed-length properties are derived recursively where
+the normalized node semantics support them. Backreference lengths and exact
+bounds for Unicode properties of strings remain unknown rather than being
+guessed.
+
+regexpp 4.12.2 does not expose tolerant recovery. A malformed pattern therefore
+gets the provider's exact diagnostic plus an application-owned error node. No
+partial regexpp AST is claimed.
+
+The explanation tree is structural. It is never called an actual execution
+trace. Selecting a node selects and scrolls its exact editor range; selecting
+pattern text chooses the smallest enclosing node.
diff --git a/docs/EXTRACTION_MODEL.md b/docs/EXTRACTION_MODEL.md
new file mode 100644
index 0000000..f1fd021
--- /dev/null
+++ b/docs/EXTRACTION_MODEL.md
@@ -0,0 +1,27 @@
+# Extraction model
+
+Extraction nodes contain actual engine match/capture values and ranges. The
+syntax tree supplies capture names and parent relationships; engine results
+supply participation, value and offsets.
+
+Statuses distinguish:
+
+- participated with non-empty text;
+- participated and matched empty;
+- did not participate;
+- unavailable range;
+- preview-truncated value.
+
+Exact match and capture ranges are retained even when a displayed value exceeds
+the 64 Ki UTF-16 per-value preview limit or the shared 8 Mi-unit preview
+budget. Consumers must inspect the preview status rather than treating a
+preview as complete. In particular, list export is disabled if its template
+would consume an incomplete value.
+
+Repeated ECMAScript captures expose only the final retained capture. The UI
+labels this limitation and does not invent history. Later .NET support may add
+actual engine capture-history nodes without degrading them to this model.
+
+The syntactic hierarchy remains useful even if actual spans overlap or fall
+outside a parent's geometry; native and normalized ranges are preserved rather
+than “fixed”.
diff --git a/docs/FLAVOUR_SUPPORT.md b/docs/FLAVOUR_SUPPORT.md
new file mode 100644
index 0000000..fe88c9e
--- /dev/null
+++ b/docs/FLAVOUR_SUPPORT.md
@@ -0,0 +1,20 @@
+# Flavour support
+
+| Flavour | Syntax | Execution | Replacement | Captures | Trace | Native offsets |
+| ------------ | ------------------------------------------------------------------ | ----------------------------------------------- | ---------------------------------------------------- | -------------------------------------------------- | ----------- | ------------------- |
+| ECMAScript | regexpp 4.12.2 grammar; explanations partial/feature-matrix tested | Current browser `RegExp`; feature-matrix tested | Native matches; bounded ECMAScript `GetSubstitution` | Named/numbered; final repeated capture; no history | Unavailable | UTF-16 |
+| PCRE2 | Unavailable in release | Technical spike only; not shipped | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
+| PCRE | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable |
+| Python `re` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned code points |
+| Go `regexp` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
+| Rust `regex` | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-8 bytes |
+| .NET | Unavailable | Unavailable | Unavailable | Planned capture history | Unavailable | Planned UTF-16 |
+| Java | Unavailable | Unavailable | Unavailable | Unavailable | Unavailable | Planned UTF-16 |
+
+ECMAScript tests cover `g`, `y`, `u`, internal `d`, zero-length iteration,
+named groups, unmatched/empty/repeated groups, lookbehind, backreferences,
+Unicode properties, astral input, replacement and result truncation.
+
+Actual browser identity is displayed because ECMAScript behavior can evolve
+with the runtime. Syntax acceptance and engine compilation are separate;
+neither silently rewrites the pattern.
diff --git a/docs/PARSER_PROFILES.md b/docs/PARSER_PROFILES.md
new file mode 100644
index 0000000..b92f89c
--- /dev/null
+++ b/docs/PARSER_PROFILES.md
@@ -0,0 +1,16 @@
+# Syntax provider profile
+
+Regex Tools has one public profile:
+
+```text
+community
+```
+
+It uses only reviewed open-source dependencies and builds without private
+registry credentials. Version 0.1.0 parses ECMAScript with regexpp 4.12.2.
+
+The product deliberately does not implement or reference an `@r101/parser`
+profile. No commercial package alias, import, tarball, credential, licence key
+or private CI path exists. Additional flavour syntax will be implemented
+incrementally as open-source providers behind the same application-owned
+interface.
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
new file mode 100644
index 0000000..cbf1776
--- /dev/null
+++ b/docs/PERFORMANCE.md
@@ -0,0 +1,66 @@
+# Performance
+
+Live parsing is debounced by 120 ms and live execution by 220 ms. Live runs use
+a 250 ms wall-clock limit; manual runs default to 2 seconds and can be raised to
+the configured 10-second maximum.
+
+Tests exercise bounded parsing, execution, zero-length iteration, result
+truncation and worker recovery. Browser performance depends on hardware,
+browser, engine version, pattern and subject. The UI therefore reports observed
+elapsed time and exact runtime identity but makes no universal throughput or
+safety claim.
+
+The production bundle separates syntax and execution workers. Interactive
+syntax/extraction trees and editor decoration sets render at most 2,000 items
+each and report both the rendered and actual totals. These are presentation
+limits only: normalized syntax, exact ranges and bounded engine results remain
+available to the capture table and eligible exports.
+
+Replacement templates have a 64 Ki UTF-16 input limit. Every token below that
+limit remains in the syntax model; the editor decorates at most 2,000 tokens,
+the token list renders 500 rows, and at most 200 replacement diagnostics
+(including an omission summary) cross into the UI. List templates have a 16 Ki
+UTF-16 input limit and render 250 token chips.
+
+The per-match replacement view renders at most 200 matches and 2,000
+contributions, with a combined 50,000 token-evaluation budget. Original,
+replacement and individual contribution text are previewed separately from the
+bounded output.
+
+Capture rows are collected under the central 100,000-row limit and rendered in
+pages of 200. Individual result-value previews are capped at 64 Ki UTF-16 units
+and share an 8 Mi-unit aggregate engine-result budget. The extraction tree
+shows 100 UTF-16 units per value and capture-table cells show 512, with explicit
+range lengths and separate engine-prefix labels; copy and eligible JSON export
+retain the bounded engine value. List export is disabled when a source value is incomplete, so a bounded
+preview cannot be mistaken for the full value. List generation additionally
+stops at 250,000 template-token evaluations, a 64 Ki UTF-16 row preview, or an
+8 Mi-unit aggregate materialization bound. The UI shows 500 list rows while an
+eligible export still contains every generated row; any incomplete result
+disables export.
+Replacement output has a separate 64 MiB bound. Match/capture-limit
+incompleteness and output-byte truncation are tracked separately; either makes
+the aggregate replacement result incomplete.
+
+Unit-test cases run through supervisors separate from the interactive workers.
+Each case uses its configured timeout, cancellation kills the active worker,
+and the suite has a 60-second aggregate wall limit. Suite state is capped at
+1,000 cases; add, clone, current-result capture and JSON append-import all
+enforce that cap and preflight a 32 MiB aggregate in-memory suite bound. Test
+names, patterns, subjects, replacement templates and expected values have
+explicit per-field limits; subjects must also fit the execution engine's UTF-8
+byte cap. Failure diagnostics preview only bounded portions of expected and
+actual values and retain at most 2 KiB UTF-8 per result, keeping 1,000 result
+messages below the 2 MiB log budget. A complete replacement output above the
+expected-value or aggregate-suite limit is not copied into draft state; a
+lightweight `should-match` draft is offered when applicable.
+
+Project import, export and local save have a 32 MiB aggregate UTF-8 JSON limit
+in addition to field-specific limits. Standalone test-suite import has the same
+32 MiB pre-parse file/document limit. Export and save count the indented JSON
+representation against a byte budget before materializing or writing it.
+IndexedDB schema version 2 indexes `updatedAt`; latest-project loading opens one
+descending cursor and validates only that record instead of calling `getAll()`.
+
+Formal cold/warm benchmarks, p95 statistics and growth charts are deferred to
+the analysis milestone and will run in killable workers.
diff --git a/docs/PORTAL_REQUIREMENTS.md b/docs/PORTAL_REQUIREMENTS.md
new file mode 100644
index 0000000..c6f2530
--- /dev/null
+++ b/docs/PORTAL_REQUIREMENTS.md
@@ -0,0 +1,19 @@
+# Portal requirements
+
+Regex Tools 0.1.0 is a static nested-path-safe application.
+
+Required delivery behavior:
+
+- JavaScript workers use a JavaScript MIME type.
+- `toolbox-app.json`, HTML and legal/source documents should remain
+ revalidation/no-cache resources.
+- Hashed Vite assets may use immutable long-term caching.
+- CSP must allow `worker-src 'self' blob:`.
+- No WebAssembly MIME rule or `wasm-unsafe-eval` is required by v0.1.0.
+
+The Portal must consume the immutable release ZIP, verify its SHA-256, verify
+manifest ID `de.add-ideas.regex-tools` and version `0.1.0`, and mount it at a
+relative target such as `apps/regex/`.
+
+When PCRE2 ships later, the smallest additional change will be
+`application/wasm` delivery and `script-src 'self' 'wasm-unsafe-eval'`.
diff --git a/docs/REFERENCE_IMPLEMENTATIONS.md b/docs/REFERENCE_IMPLEMENTATIONS.md
new file mode 100644
index 0000000..8883c03
--- /dev/null
+++ b/docs/REFERENCE_IMPLEMENTATIONS.md
@@ -0,0 +1,21 @@
+# Reference implementations and sources
+
+Inspection date: 2026-07-24.
+
+| Reference | Exact revision/version | Licence | Use and copying status |
+| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------------- |
+| Regex Tools | Unborn repository at initial inspection; release identity `v0.1.0` | GPL-3.0-or-later | Application source written for this project. |
+| Toolbox SDK | `53c40a61ba1581246f65773fcbb1c1cfd31ac98e` / 0.2.2 | Apache-2.0 | Contract, shell and release conventions; package APIs used, no source adapted. |
+| Toolbox Portal | `a9c31c8986c40a0097966318e925083302e91e13` / 0.5.0 | AGPL-3.0-only | Assembly/lock conventions inspected; no source copied into the app. |
+| regexpp | 4.12.2; npm integrity `sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==` | MIT | Pinned ECMAScript syntax provider. Provider AST is normalized in a worker. |
+| PCRE2 | signed tag 10.47; commit `f454e231fe5006dd7ff8f4693fd2b8eb94333429` | BSD-3-Clause WITH PCRE2-exception | External technical spike only; not shipped or copied. |
+| ECMAScript specification | ; living specification inspected 2026-07-24 | ECMA terms | Semantics reference; no prose copied. |
+| regex101 | and public parser documentation | Product/commercial terms | Product reference only. No source, branding, explanations, reference prose or visual design copied. |
+| `recheck` | 4.5.0 | MIT | Metadata inspected; not installed or shipped. |
+| `regexp-ast-analysis` | 0.7.1 | MIT | Metadata inspected; not installed or shipped. |
+| RegexHub | repository revision `d1d9f19d259745dfdd21935b9ef3e747b62b9bfb` | MIT | Potential future adversarial cases; no file copied. |
+| RegexLib | Website inspected | Redistribution licence not established | No fixture copied. |
+| RGXP.RU | Website inspected | Redistribution licence not established | No fixture copied. |
+
+All v0.1.0 conformance cases are project-authored. See
+`tests/fixtures/README.md`.
diff --git a/docs/RELEASE.md b/docs/RELEASE.md
new file mode 100644
index 0000000..7fa1c86
--- /dev/null
+++ b/docs/RELEASE.md
@@ -0,0 +1,23 @@
+# Release process
+
+1. Ensure the intended commit is clean and tagged.
+2. Install the exact lockfile with `npm ci`.
+3. Run `npm run release:artifact`.
+4. Record the reported SHA-256, then run
+ `npm run package:release -- --force` twice and verify both reported digests
+ match it. Inspect an existing exact-version artifact before deliberately
+ replacing it.
+5. Inspect ZIP paths, manifest, legal documents and absence of maps/secrets.
+6. Publish ZIP and `.sha256` on the matching Gitea release.
+7. Use the public immutable URL and digest in Toolbox Portal.
+
+The package script sorts paths without locale-dependent collation, rejects
+symlinks and credential-like paths, normalizes file timestamps and modes,
+verifies manifest/source identity, stages both outputs and rolls back the pair
+on publication failure, replaces an existing exact version only with
+`--force`, and produces:
+
+```text
+release/regex-tools-0.1.0.zip
+release/regex-tools-0.1.0.zip.sha256
+```
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
new file mode 100644
index 0000000..9b2efd0
--- /dev/null
+++ b/docs/SECURITY.md
@@ -0,0 +1,51 @@
+# Security and privacy
+
+Patterns, flags, replacement templates, subjects and imported projects are
+untrusted.
+
+- Syntax and execution use separate workers.
+- Regex execution never occurs on the UI thread.
+- Timeout, crash and cancellation terminate the worker.
+- A terminated worker is never reused.
+- Pattern, subject, replacement/list template, match, capture and output sizes
+ are bounded.
+- Replacement templates are strings; executable callbacks are unavailable.
+- Replacement/list token decorations, rows, chips, diagnostics and list
+ evaluation work have separate presentation/operation caps; incomplete list
+ results cannot be exported.
+- Per-match replacement mapping executes no user code and is capped at 200
+ matches, 2,000 materialized contributions and 50,000 token evaluations.
+- Imported JSON is aggregate-size-bounded before parsing, then field-validated
+ and never evaluated or auto-run. Export and IndexedDB save apply the same
+ 32 MiB aggregate UTF-8 document bound before materialization or persistence.
+- Project and standalone-suite test subjects are not exported by default, and
+ unit-test subjects require a separate opt-in before IndexedDB persistence.
+- Unit-test add, update, clone and append-import paths share 1,000-case and
+ 32 MiB aggregate state caps.
+- Unit-test assertion diagnostics never stringify complete large values and
+ retain at most 2 KiB UTF-8 per case. Oversized replacement output is not
+ copied into an exact current-result draft.
+- No backend, uploads, telemetry, remote corpus URL, CDN, remote font, `eval`,
+ `Function`, arbitrary command line or unsafe HTML rendering exists.
+
+Version 0.1.0 needs a CSP equivalent to:
+
+```text
+default-src 'self';
+script-src 'self';
+worker-src 'self' blob:;
+connect-src 'self';
+img-src 'self' data:;
+font-src 'self';
+style-src 'self' 'unsafe-inline';
+object-src 'none';
+frame-src 'none';
+base-uri 'none';
+form-action 'none';
+```
+
+The Toolbox shell currently requires inline styles. No `unsafe-eval` or
+`wasm-unsafe-eval` is required until an actual WebAssembly flavour ships.
+
+Report vulnerabilities privately to the repository owner before public issue
+details are posted.
diff --git a/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..f930094
--- /dev/null
+++ b/eslint.config.mjs
@@ -0,0 +1,43 @@
+import js from "@eslint/js";
+import globals from "globals";
+import reactHooks from "eslint-plugin-react-hooks";
+import reactRefresh from "eslint-plugin-react-refresh";
+import tseslint from "typescript-eslint";
+
+export default tseslint.config(
+ {
+ ignores: [
+ "dist",
+ "release",
+ "coverage",
+ "test-results",
+ "playwright-report",
+ ],
+ },
+ {
+ extends: [js.configs.recommended, ...tseslint.configs.recommended],
+ files: ["**/*.{ts,tsx}"],
+ languageOptions: {
+ ecmaVersion: 2023,
+ globals: {
+ ...globals.browser,
+ ...globals.worker,
+ },
+ },
+ plugins: {
+ "react-hooks": reactHooks,
+ "react-refresh": reactRefresh,
+ },
+ rules: {
+ ...reactHooks.configs.recommended.rules,
+ "react-refresh/only-export-components": [
+ "warn",
+ { allowConstantExport: true },
+ ],
+ },
+ },
+ {
+ files: ["scripts/**/*.mjs", "playwright.config.ts"],
+ languageOptions: { globals: globals.node },
+ },
+);
diff --git a/index.html b/index.html
new file mode 100644
index 0000000..9bdbfbc
--- /dev/null
+++ b/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+ Regex Tools
+
+
+
+
+
+
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..cf94e2b
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,4166 @@
+{
+ "name": "regex-tools",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "regex-tools",
+ "version": "0.1.0",
+ "license": "GPL-3.0-or-later",
+ "dependencies": {
+ "@add-ideas/toolbox-contract": "0.2.2",
+ "@add-ideas/toolbox-shell-react": "0.2.2",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/view": "6.43.6",
+ "@eslint-community/regexpp": "4.12.2",
+ "@lezer/highlight": "1.2.3",
+ "react": "19.2.7",
+ "react-dom": "19.2.7"
+ },
+ "devDependencies": {
+ "@add-ideas/toolbox-testkit": "0.2.2",
+ "@eslint/js": "10.0.1",
+ "@playwright/test": "1.61.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/node": "25.9.5",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "6.0.3",
+ "eslint": "10.7.0",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "eslint-plugin-react-refresh": "0.5.2",
+ "fflate": "0.8.3",
+ "globals": "17.7.0",
+ "jsdom": "29.1.1",
+ "prettier": "3.9.5",
+ "typescript": "6.0.3",
+ "typescript-eslint": "8.64.0",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "node": ">=22"
+ }
+ },
+ "node_modules/@add-ideas/toolbox-contract": {
+ "version": "0.2.2",
+ "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
+ "integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/@add-ideas/toolbox-shell-react": {
+ "version": "0.2.2",
+ "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
+ "integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@add-ideas/toolbox-contract": "0.2.2"
+ },
+ "peerDependencies": {
+ "react": ">=18 <20",
+ "react-dom": ">=18 <20"
+ }
+ },
+ "node_modules/@add-ideas/toolbox-testkit": {
+ "version": "0.2.2",
+ "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.2/toolbox-testkit-0.2.2.tgz",
+ "integrity": "sha512-ZxrRYiJI/j5aXwcd3mQItgDl1iOJ6N26+MpIG18qVZNwKKFCTCfSTzgzRooPt8rRc1MQYTPo4c+l/QpOWuTXxA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@add-ideas/toolbox-contract": "0.2.2"
+ },
+ "bin": {
+ "toolbox-check": "dist/cli.js"
+ }
+ },
+ "node_modules/@adobe/css-tools": {
+ "version": "4.5.0",
+ "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
+ "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@asamuzakjp/css-color": {
+ "version": "5.1.11",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
+ "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@csstools/css-calc": "^3.2.0",
+ "@csstools/css-color-parser": "^4.1.0",
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/dom-selector": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
+ "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/generational-cache": "^1.0.1",
+ "@asamuzakjp/nwsapi": "^2.3.9",
+ "bidi-js": "^1.0.3",
+ "css-tree": "^3.2.1",
+ "is-potential-custom-element-name": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/generational-cache": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
+ "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/@asamuzakjp/nwsapi": {
+ "version": "2.3.9",
+ "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
+ "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/runtime": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
+ "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@bramus/specificity": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
+ "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "css-tree": "^3.0.0"
+ },
+ "bin": {
+ "specificity": "bin/cli.js"
+ }
+ },
+ "node_modules/@codemirror/commands": {
+ "version": "6.10.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz",
+ "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/language": "^6.0.0",
+ "@codemirror/state": "^6.7.0",
+ "@codemirror/view": "^6.27.0",
+ "@lezer/common": "^1.1.0"
+ }
+ },
+ "node_modules/@codemirror/language": {
+ "version": "6.12.4",
+ "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
+ "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.23.0",
+ "@lezer/common": "^1.5.0",
+ "@lezer/highlight": "^1.0.0",
+ "@lezer/lr": "^1.0.0",
+ "style-mod": "^4.0.0"
+ }
+ },
+ "node_modules/@codemirror/search": {
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz",
+ "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.0.0",
+ "@codemirror/view": "^6.37.0",
+ "crelt": "^1.0.5"
+ }
+ },
+ "node_modules/@codemirror/state": {
+ "version": "6.7.1",
+ "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz",
+ "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==",
+ "license": "MIT",
+ "dependencies": {
+ "@marijn/find-cluster-break": "^1.0.0"
+ }
+ },
+ "node_modules/@codemirror/view": {
+ "version": "6.43.6",
+ "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz",
+ "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==",
+ "license": "MIT",
+ "dependencies": {
+ "@codemirror/state": "^6.7.0",
+ "crelt": "^1.0.6",
+ "style-mod": "^4.1.0",
+ "w3c-keyname": "^2.2.4"
+ }
+ },
+ "node_modules/@csstools/color-helpers": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz",
+ "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@csstools/css-calc": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz",
+ "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-color-parser": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz",
+ "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "@csstools/color-helpers": "^6.1.0",
+ "@csstools/css-calc": "^3.3.0"
+ },
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-parser-algorithms": "^4.0.0",
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-parser-algorithms": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
+ "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "peerDependencies": {
+ "@csstools/css-tokenizer": "^4.0.0"
+ }
+ },
+ "node_modules/@csstools/css-syntax-patches-for-csstree": {
+ "version": "1.1.7",
+ "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz",
+ "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT-0",
+ "peerDependencies": {
+ "css-tree": "^3.2.1"
+ },
+ "peerDependenciesMeta": {
+ "css-tree": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@csstools/css-tokenizer": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
+ "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/csstools"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/csstools"
+ }
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19.0"
+ }
+ },
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils": {
+ "version": "4.10.1",
+ "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz",
+ "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0"
+ }
+ },
+ "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz",
+ "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^12.22.0 || ^14.17.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/@eslint-community/regexpp": {
+ "version": "4.12.2",
+ "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz",
+ "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==",
+ "license": "MIT",
+ "engines": {
+ "node": "^12.0.0 || ^14.0.0 || >=16.0.0"
+ }
+ },
+ "node_modules/@eslint/config-array": {
+ "version": "0.23.5",
+ "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz",
+ "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/object-schema": "^3.0.5",
+ "debug": "^4.3.1",
+ "minimatch": "^10.2.4"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/config-helpers": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz",
+ "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/core": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz",
+ "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@types/json-schema": "^7.0.15"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/js": {
+ "version": "10.0.1",
+ "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
+ "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "eslint": "^10.0.0"
+ },
+ "peerDependenciesMeta": {
+ "eslint": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@eslint/object-schema": {
+ "version": "3.0.5",
+ "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz",
+ "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@eslint/plugin-kit": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz",
+ "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@eslint/core": "^1.2.1",
+ "levn": "^0.4.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ }
+ },
+ "node_modules/@exodus/bytes": {
+ "version": "1.15.1",
+ "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz",
+ "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "@noble/hashes": "^1.8.0 || ^2.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@noble/hashes": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@humanfs/core": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
+ "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/types": "^0.15.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/node": {
+ "version": "0.16.8",
+ "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
+ "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@humanfs/core": "^0.19.2",
+ "@humanfs/types": "^0.15.0",
+ "@humanwhocodes/retry": "^0.4.0"
+ },
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanfs/types": {
+ "version": "0.15.0",
+ "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
+ "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18.0"
+ }
+ },
+ "node_modules/@humanwhocodes/module-importer": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
+ "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.22"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@humanwhocodes/retry": {
+ "version": "0.4.3",
+ "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz",
+ "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/nzakas"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@lezer/common": {
+ "version": "1.5.2",
+ "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz",
+ "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==",
+ "license": "MIT"
+ },
+ "node_modules/@lezer/highlight": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz",
+ "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.3.0"
+ }
+ },
+ "node_modules/@lezer/lr": {
+ "version": "1.4.10",
+ "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
+ "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
+ "license": "MIT",
+ "dependencies": {
+ "@lezer/common": "^1.0.0"
+ }
+ },
+ "node_modules/@marijn/find-cluster-break": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz",
+ "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==",
+ "license": "MIT"
+ },
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.139.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz",
+ "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@playwright/test": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
+ "integrity": "sha512-8nKv6+0RJSL9FE4jYOEGXnPeM/Hg12qZpmqzZjRh3qM0Y7c3z1mrOTfFLids72RDQYVh9WpLEfR5WdpNX4fkig==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@rolldown/binding-android-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
+ "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz",
+ "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-darwin-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz",
+ "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-freebsd-x64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz",
+ "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm-gnueabihf": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz",
+ "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz",
+ "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-arm64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz",
+ "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-ppc64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz",
+ "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-s390x-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz",
+ "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-gnu": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz",
+ "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-linux-x64-musl": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz",
+ "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-openharmony-arm64": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz",
+ "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-wasm32-wasi": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz",
+ "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.6"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-arm64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz",
+ "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/binding-win32-x64-msvc": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz",
+ "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
+ "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/dom": {
+ "version": "10.4.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
+ "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "@babel/code-frame": "^7.10.4",
+ "@babel/runtime": "^7.12.5",
+ "@types/aria-query": "^5.0.1",
+ "aria-query": "5.3.0",
+ "dom-accessibility-api": "^0.5.9",
+ "lz-string": "^1.5.0",
+ "picocolors": "1.1.1",
+ "pretty-format": "^27.0.2"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@testing-library/jest-dom": {
+ "version": "6.9.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
+ "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@adobe/css-tools": "^4.4.0",
+ "aria-query": "^5.0.0",
+ "css.escape": "^1.5.1",
+ "dom-accessibility-api": "^0.6.3",
+ "picocolors": "^1.1.1",
+ "redent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=14",
+ "npm": ">=6",
+ "yarn": ">=1"
+ }
+ },
+ "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
+ "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@testing-library/react": {
+ "version": "16.3.2",
+ "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz",
+ "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": "^10.0.0",
+ "@types/react": "^18.0.0 || ^19.0.0",
+ "@types/react-dom": "^18.0.0 || ^19.0.0",
+ "react": "^18.0.0 || ^19.0.0",
+ "react-dom": "^18.0.0 || ^19.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "@types/react-dom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@testing-library/user-event": {
+ "version": "14.6.1",
+ "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
+ "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "@testing-library/dom": ">=7.21.4"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@types/aria-query": {
+ "version": "5.0.4",
+ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
+ "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/esrecurse": {
+ "version": "4.3.1",
+ "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
+ "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/json-schema": {
+ "version": "7.0.15",
+ "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.9.5",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.5.tgz",
+ "integrity": "sha512-OScDchr2fwuUmWdf4kZ9h7PcJiYDVInhJizG/biAq3cAvqwYktuy/TYGGdZNMtNTFUP7rnb0NU4TUdm82kt4Rg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": ">=7.24.0 <7.24.7"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.64.0.tgz",
+ "integrity": "sha512-CGvQPBxN3wZLu6Rz2kFUpZeoCm78xUic92ck39KPePkO1NPOwjCqdQnm5Q87tpWw9vcBvW8XLrDXjH9PWYtJ3Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/regexpp": "^4.12.2",
+ "@typescript-eslint/scope-manager": "8.64.0",
+ "@typescript-eslint/type-utils": "8.64.0",
+ "@typescript-eslint/utils": "8.64.0",
+ "@typescript-eslint/visitor-keys": "8.64.0",
+ "ignore": "^7.0.5",
+ "natural-compare": "^1.4.0",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@typescript-eslint/parser": "^8.64.0",
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.6.tgz",
+ "integrity": "sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/@typescript-eslint/parser": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.64.0.tgz",
+ "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/scope-manager": "8.64.0",
+ "@typescript-eslint/types": "8.64.0",
+ "@typescript-eslint/typescript-estree": "8.64.0",
+ "@typescript-eslint/visitor-keys": "8.64.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/project-service": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.64.0.tgz",
+ "integrity": "sha512-tk4WpOJ6IEbGrVHaNmM0YRrwAD3exZlIK3iadQNAxh4YKk6jvUQ4ecq18n+v7+meh+cJ3j+D8nbk8sRKhlwLQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/tsconfig-utils": "^8.64.0",
+ "@typescript-eslint/types": "^8.64.0",
+ "debug": "^4.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/scope-manager": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.64.0.tgz",
+ "integrity": "sha512-CXEaFdYXjSTgKhisNkwCcJwTP8Pl+fmRrEQrri4nm3vU743bALrxzLmq7fHG/7e6a5xO0lDYeURpZmBuhHk54w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.64.0",
+ "@typescript-eslint/visitor-keys": "8.64.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/tsconfig-utils": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.64.0.tgz",
+ "integrity": "sha512-2yo8rRNKuzbVWQp5kslhANqZ2uDAeROQHBRZNPu8JDsHmeFNj/XJJhX/FhNUWmkHHvoNsKa6+tHJiig87EzsQw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.64.0.tgz",
+ "integrity": "sha512-XWG4Fmmv/6SvyS9nH8jWrKs6terwJvE8cyRt1CzYYqzp9OrPhCT4cMc/f7C6RZCwG+qMmiffJS1/qJP8G1URtg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.64.0",
+ "@typescript-eslint/typescript-estree": "8.64.0",
+ "@typescript-eslint/utils": "8.64.0",
+ "debug": "^4.4.3",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/types": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.64.0.tgz",
+ "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.64.0.tgz",
+ "integrity": "sha512-Pztpsn1aCE1oWDvDEfUk31nngvvF7vUB5SwHFEaZIFpvw7WJtqUHHL4plBZDA9HfWJJjL13BdG0YrJInTUvoVA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/project-service": "8.64.0",
+ "@typescript-eslint/tsconfig-utils": "8.64.0",
+ "@typescript-eslint/types": "8.64.0",
+ "@typescript-eslint/visitor-keys": "8.64.0",
+ "debug": "^4.4.3",
+ "minimatch": "^10.2.2",
+ "semver": "^7.7.3",
+ "tinyglobby": "^0.2.15",
+ "ts-api-utils": "^2.5.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/@typescript-eslint/utils": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.64.0.tgz",
+ "integrity": "sha512-aJUGVB3+U0htrrCjoA8qukw8cm8fNCGAxK/tVoS70k8aeb7DETKeFozRiVFIwEeN9WJLsjaP3ph8I60tY2XZoQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.9.1",
+ "@typescript-eslint/scope-manager": "8.64.0",
+ "@typescript-eslint/types": "8.64.0",
+ "@typescript-eslint/typescript-estree": "8.64.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/@typescript-eslint/visitor-keys": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.64.0.tgz",
+ "integrity": "sha512-mrtuL8Nsn6gi2H4mo5KMTp823M+3Q19Ew/i+Zlikq20tIMm99C3Ez0dCmkWWnxut20esQvTg8aUSEhMcAOXhEw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "8.64.0",
+ "eslint-visitor-keys": "^5.0.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz",
+ "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "^1.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
+ "babel-plugin-react-compiler": "^1.0.0",
+ "vite": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@rolldown/plugin-babel": {
+ "optional": true
+ },
+ "babel-plugin-react-compiler": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz",
+ "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.1.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "chai": "^6.2.2",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz",
+ "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.1.10",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz",
+ "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz",
+ "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.1.10",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz",
+ "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz",
+ "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz",
+ "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.1.10",
+ "convert-source-map": "^2.0.0",
+ "tinyrainbow": "^3.1.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/acorn": {
+ "version": "8.17.0",
+ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
+ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "acorn": "bin/acorn"
+ },
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
+ "node_modules/acorn-jsx": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
+ "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "6.15.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
+ "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.1",
+ "fast-json-stable-stringify": "^2.0.0",
+ "json-schema-traverse": "^0.4.1",
+ "uri-js": "^4.2.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ansi-regex": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+ "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/ansi-styles": {
+ "version": "5.2.0",
+ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
+ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/chalk/ansi-styles?sponsor=1"
+ }
+ },
+ "node_modules/aria-query": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
+ "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "dequal": "^2.0.3"
+ }
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/balanced-match": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
+ "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "18 || 20 || >=22"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.11.1",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
+ "integrity": "sha512-HYXq73DDpCtNzOmrFsm9eSwCvWCql0RzqjpDzXN9EadiLJ4DNat0nsZ/Bzmy+Ud12mb4/zKDY0cQ805ZzN+i0A==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/bidi-js": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
+ "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "require-from-string": "^2.0.2"
+ }
+ },
+ "node_modules/brace-expansion": {
+ "version": "5.0.8",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
+ "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^4.0.2"
+ },
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.7",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz",
+ "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.44",
+ "caniuse-lite": "^1.0.30001806",
+ "electron-to-chromium": "^1.5.393",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/crelt": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
+ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
+ "license": "MIT"
+ },
+ "node_modules/cross-spawn": {
+ "version": "7.0.6",
+ "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
+ "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "path-key": "^3.1.0",
+ "shebang-command": "^2.0.0",
+ "which": "^2.0.1"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/css-tree": {
+ "version": "3.2.1",
+ "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
+ "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "mdn-data": "2.27.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
+ }
+ },
+ "node_modules/css.escape": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
+ "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/data-urls": {
+ "version": "7.0.0",
+ "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
+ "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/decimal.js": {
+ "version": "10.6.0",
+ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
+ "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/deep-is": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
+ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/dequal": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/dom-accessibility-api": {
+ "version": "0.5.16",
+ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
+ "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.396",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.396.tgz",
+ "integrity": "sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/entities": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
+ "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20.19.0"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
+ "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/escape-string-regexp": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
+ "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/eslint": {
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.7.0.tgz",
+ "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
+ "dev": true,
+ "license": "MIT",
+ "workspaces": [
+ "packages/*"
+ ],
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.8.0",
+ "@eslint-community/regexpp": "^4.12.2",
+ "@eslint/config-array": "^0.23.5",
+ "@eslint/config-helpers": "^0.6.0",
+ "@eslint/core": "^1.2.1",
+ "@eslint/plugin-kit": "^0.7.2",
+ "@humanfs/node": "^0.16.6",
+ "@humanwhocodes/module-importer": "^1.0.1",
+ "@humanwhocodes/retry": "^0.4.2",
+ "@types/estree": "^1.0.6",
+ "ajv": "^6.14.0",
+ "cross-spawn": "^7.0.6",
+ "debug": "^4.3.2",
+ "escape-string-regexp": "^4.0.0",
+ "eslint-scope": "^9.1.2",
+ "eslint-visitor-keys": "^5.0.1",
+ "espree": "^11.2.0",
+ "esquery": "^1.7.0",
+ "esutils": "^2.0.2",
+ "fast-deep-equal": "^3.1.3",
+ "file-entry-cache": "^8.0.0",
+ "find-up": "^5.0.0",
+ "glob-parent": "^6.0.2",
+ "ignore": "^5.2.0",
+ "imurmurhash": "^0.1.4",
+ "is-glob": "^4.0.0",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "minimatch": "^10.2.4",
+ "natural-compare": "^1.4.0",
+ "optionator": "^0.9.3"
+ },
+ "bin": {
+ "eslint": "bin/eslint.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://eslint.org/donate"
+ },
+ "peerDependencies": {
+ "jiti": "*"
+ },
+ "peerDependenciesMeta": {
+ "jiti": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/eslint-plugin-react-hooks": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz",
+ "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.24.4",
+ "@babel/parser": "^7.24.4",
+ "hermes-parser": "^0.25.1",
+ "zod": "^3.25.0 || ^4.0.0",
+ "zod-validation-error": "^3.5.0 || ^4.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-react-refresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz",
+ "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "eslint": "^9 || ^10"
+ }
+ },
+ "node_modules/eslint-scope": {
+ "version": "9.1.2",
+ "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz",
+ "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@types/esrecurse": "^4.3.1",
+ "@types/estree": "^1.0.8",
+ "esrecurse": "^4.3.0",
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/eslint-visitor-keys": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
+ "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/espree": {
+ "version": "11.2.0",
+ "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz",
+ "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "acorn": "^8.16.0",
+ "acorn-jsx": "^5.3.2",
+ "eslint-visitor-keys": "^5.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint"
+ }
+ },
+ "node_modules/esquery": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz",
+ "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "estraverse": "^5.1.0"
+ },
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/esrecurse": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz",
+ "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "estraverse": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estraverse": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz",
+ "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=4.0"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/esutils": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
+ "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/expect-type": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
+ "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-json-stable-stringify": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
+ "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fast-levenshtein": {
+ "version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz",
+ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fflate": {
+ "version": "0.8.3",
+ "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz",
+ "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/file-entry-cache": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
+ "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flat-cache": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=16.0.0"
+ }
+ },
+ "node_modules/find-up": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
+ "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "locate-path": "^6.0.0",
+ "path-exists": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/flat-cache": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+ "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "flatted": "^3.2.9",
+ "keyv": "^4.5.4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/flatted": {
+ "version": "3.4.3",
+ "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.3.tgz",
+ "integrity": "sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.2",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+ "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/glob-parent": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
+ "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "is-glob": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/globals": {
+ "version": "17.7.0",
+ "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
+ "integrity": "sha512-Czmyns5dUsq4seFBR/Kdydhmo8y9kC79hiSkPn0YcGtNnYWnrgt0vjrSjx9tspoDGWm2CMarffRuLjM4xUz8xg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/hermes-estree": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
+ "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/hermes-parser": {
+ "version": "0.25.1",
+ "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz",
+ "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "hermes-estree": "0.25.1"
+ }
+ },
+ "node_modules/html-encoding-sniffer": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
+ "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.6.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/ignore": {
+ "version": "5.3.2",
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
+ "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 4"
+ }
+ },
+ "node_modules/imurmurhash": {
+ "version": "0.1.4",
+ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz",
+ "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.8.19"
+ }
+ },
+ "node_modules/indent-string": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
+ "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/is-extglob": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
+ "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-glob": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
+ "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "is-extglob": "^2.1.1"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/is-potential-custom-element-name": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
+ "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/isexe": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
+ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsdom": {
+ "version": "29.1.1",
+ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
+ "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@asamuzakjp/css-color": "^5.1.11",
+ "@asamuzakjp/dom-selector": "^7.1.1",
+ "@bramus/specificity": "^2.4.2",
+ "@csstools/css-syntax-patches-for-csstree": "^1.1.3",
+ "@exodus/bytes": "^1.15.0",
+ "css-tree": "^3.2.1",
+ "data-urls": "^7.0.0",
+ "decimal.js": "^10.6.0",
+ "html-encoding-sniffer": "^6.0.0",
+ "is-potential-custom-element-name": "^1.0.1",
+ "lru-cache": "^11.3.5",
+ "parse5": "^8.0.1",
+ "saxes": "^6.0.0",
+ "symbol-tree": "^3.2.4",
+ "tough-cookie": "^6.0.1",
+ "undici": "^7.25.0",
+ "w3c-xmlserializer": "^5.0.0",
+ "webidl-conversions": "^8.0.1",
+ "whatwg-mimetype": "^5.0.0",
+ "whatwg-url": "^16.0.1",
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.13.0 || >=24.0.0"
+ },
+ "peerDependencies": {
+ "canvas": "^3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "canvas": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/jsdom/node_modules/lru-cache": {
+ "version": "11.5.2",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
+ "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "engines": {
+ "node": "20 || >=22"
+ }
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json-buffer": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
+ "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
+ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json-stable-stringify-without-jsonify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
+ "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/keyv": {
+ "version": "4.5.4",
+ "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
+ "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "json-buffer": "3.0.1"
+ }
+ },
+ "node_modules/levn": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
+ "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1",
+ "type-check": "~0.4.0"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
+ "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.33.0",
+ "lightningcss-darwin-arm64": "1.33.0",
+ "lightningcss-darwin-x64": "1.33.0",
+ "lightningcss-freebsd-x64": "1.33.0",
+ "lightningcss-linux-arm-gnueabihf": "1.33.0",
+ "lightningcss-linux-arm64-gnu": "1.33.0",
+ "lightningcss-linux-arm64-musl": "1.33.0",
+ "lightningcss-linux-x64-gnu": "1.33.0",
+ "lightningcss-linux-x64-musl": "1.33.0",
+ "lightningcss-win32-arm64-msvc": "1.33.0",
+ "lightningcss-win32-x64-msvc": "1.33.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
+ "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
+ "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
+ "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
+ "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
+ "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
+ "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
+ "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
+ "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
+ "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
+ "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.33.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
+ "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/locate-path": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
+ "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-locate": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/lz-string": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
+ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "bin": {
+ "lz-string": "bin/bin.js"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mdn-data": {
+ "version": "2.27.1",
+ "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
+ "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
+ "dev": true,
+ "license": "CC0-1.0"
+ },
+ "node_modules/min-indent": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
+ "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/minimatch": {
+ "version": "10.2.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
+ "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
+ "dev": true,
+ "license": "BlueOak-1.0.0",
+ "dependencies": {
+ "brace-expansion": "^5.0.5"
+ },
+ "engines": {
+ "node": "18 || 20 || >=22"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/natural-compare": {
+ "version": "1.4.0",
+ "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
+ "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.4",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.4.tgz",
+ "integrity": "sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.20.0"
+ }
+ },
+ "node_modules/optionator": {
+ "version": "0.9.4",
+ "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
+ "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "deep-is": "^0.1.3",
+ "fast-levenshtein": "^2.0.6",
+ "levn": "^0.4.1",
+ "prelude-ls": "^1.2.1",
+ "type-check": "^0.4.0",
+ "word-wrap": "^1.2.5"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/p-limit": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
+ "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "yocto-queue": "^0.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/p-locate": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz",
+ "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "p-limit": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/parse5": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
+ "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "entities": "^8.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/inikulin/parse5?sponsor=1"
+ }
+ },
+ "node_modules/path-exists": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
+ "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/path-key": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
+ "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/playwright": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.1.tgz",
+ "integrity": "sha512-DWnY5o3YbLWK4GovuAVwpqL+1VwGNdUGrRr++8j8PtQQzvAVZUIMjKQ90fY689sEJZJBbZVw1rXaOKSTitkzPQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "dependencies": {
+ "playwright-core": "1.61.1"
+ },
+ "bin": {
+ "playwright": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "fsevents": "2.3.2"
+ }
+ },
+ "node_modules/playwright-core": {
+ "version": "1.61.1",
+ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.1.tgz",
+ "integrity": "sha512-h7Qlt6m4REp25qvIdvbDtVmD4LqVXfpRxhORv9L0jzETM05p4fuPJ3dKyuSXQxDSbXnmS79HAgi9589lGSpLkg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "playwright-core": "cli.js"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.22",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.22.tgz",
+ "integrity": "sha512-KBDEIpLrvpv16pp3K0Fw+UCoZfopFjjgeB+0tA/aaThfEE74kKDLrgg603YvOWJyg3+WYtyq3xYsQWsIyZlPqQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.16",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/prelude-ls": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
+ "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/prettier": {
+ "version": "3.9.5",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.5.tgz",
+ "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "prettier": "bin/prettier.cjs"
+ },
+ "engines": {
+ "node": ">=14"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
+ "node_modules/pretty-format": {
+ "version": "27.5.1",
+ "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
+ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true,
+ "dependencies": {
+ "ansi-regex": "^5.0.1",
+ "ansi-styles": "^5.0.0",
+ "react-is": "^17.0.1"
+ },
+ "engines": {
+ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
+ }
+ },
+ "node_modules/punycode": {
+ "version": "2.3.1",
+ "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
+ "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-is": {
+ "version": "17.0.2",
+ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
+ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
+ "dev": true,
+ "license": "MIT",
+ "peer": true
+ },
+ "node_modules/redent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
+ "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "indent-string": "^4.0.0",
+ "strip-indent": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rolldown": {
+ "version": "1.1.5",
+ "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz",
+ "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "=0.139.0",
+ "@rolldown/pluginutils": "^1.0.0"
+ },
+ "bin": {
+ "rolldown": "bin/cli.mjs"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "optionalDependencies": {
+ "@rolldown/binding-android-arm64": "1.1.5",
+ "@rolldown/binding-darwin-arm64": "1.1.5",
+ "@rolldown/binding-darwin-x64": "1.1.5",
+ "@rolldown/binding-freebsd-x64": "1.1.5",
+ "@rolldown/binding-linux-arm-gnueabihf": "1.1.5",
+ "@rolldown/binding-linux-arm64-gnu": "1.1.5",
+ "@rolldown/binding-linux-arm64-musl": "1.1.5",
+ "@rolldown/binding-linux-ppc64-gnu": "1.1.5",
+ "@rolldown/binding-linux-s390x-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-gnu": "1.1.5",
+ "@rolldown/binding-linux-x64-musl": "1.1.5",
+ "@rolldown/binding-openharmony-arm64": "1.1.5",
+ "@rolldown/binding-wasm32-wasi": "1.1.5",
+ "@rolldown/binding-win32-arm64-msvc": "1.1.5",
+ "@rolldown/binding-win32-x64-msvc": "1.1.5"
+ }
+ },
+ "node_modules/saxes": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
+ "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "xmlchars": "^2.2.0"
+ },
+ "engines": {
+ "node": ">=v12.22.7"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/shebang-command": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
+ "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "shebang-regex": "^3.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/shebang-regex": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
+ "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
+ "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/strip-indent": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
+ "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "min-indent": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/style-mod": {
+ "version": "4.1.3",
+ "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz",
+ "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==",
+ "license": "MIT"
+ },
+ "node_modules/symbol-tree": {
+ "version": "3.2.4",
+ "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
+ "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.2.4",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz",
+ "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
+ "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/tldts": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz",
+ "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tldts-core": "^7.4.9"
+ },
+ "bin": {
+ "tldts": "bin/cli.js"
+ }
+ },
+ "node_modules/tldts-core": {
+ "version": "7.4.9",
+ "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz",
+ "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tough-cookie": {
+ "version": "6.0.2",
+ "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz",
+ "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "tldts": "^7.0.5"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tr46": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
+ "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "punycode": "^2.3.1"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/ts-api-utils": {
+ "version": "2.5.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
+ "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.12"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.8.4"
+ }
+ },
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
+ "node_modules/type-check": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
+ "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prelude-ls": "^1.2.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "6.0.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+ "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/typescript-eslint": {
+ "version": "8.64.0",
+ "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.64.0.tgz",
+ "integrity": "sha512-0qg+pDNMnqYzqH9AnNK+39tejHvsShUOUUoRUgtnTGE7QuMZhiFDnozq8nHJVq+Wae6NMLKNWLg5WmkcC/ndyQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/eslint-plugin": "8.64.0",
+ "@typescript-eslint/parser": "8.64.0",
+ "@typescript-eslint/typescript-estree": "8.64.0",
+ "@typescript-eslint/utils": "8.64.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
+ "typescript": ">=4.8.4 <6.1.0"
+ }
+ },
+ "node_modules/undici": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
+ "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.18.1"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.24.6",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
+ "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/uri-js": {
+ "version": "4.4.1",
+ "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
+ "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "punycode": "^2.1.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "8.1.5",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.5.tgz",
+ "integrity": "sha512-7ULLwsCdYx/nRyrpiEwvqb5TFHrMVZyBt+rg/OAXT7rgj/z+DtTDyKFeLAdDkubDVDKD8jOsndmy7m55XcfUsw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "lightningcss": "^1.32.0",
+ "picomatch": "^4.0.5",
+ "postcss": "^8.5.17",
+ "rolldown": "~1.1.5",
+ "tinyglobby": "^0.2.17"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "@vitejs/devtools": "^0.3.0",
+ "esbuild": "^0.27.0 || ^0.28.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@vitejs/devtools": {
+ "optional": true
+ },
+ "esbuild": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vite/node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.1.10",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz",
+ "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.1.10",
+ "@vitest/mocker": "4.1.10",
+ "@vitest/pretty-format": "4.1.10",
+ "@vitest/runner": "4.1.10",
+ "@vitest/snapshot": "4.1.10",
+ "@vitest/spy": "4.1.10",
+ "@vitest/utils": "4.1.10",
+ "es-module-lexer": "^2.0.0",
+ "expect-type": "^1.3.0",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^4.0.0-rc.1",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.1.0",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.1.10",
+ "@vitest/browser-preview": "4.1.10",
+ "@vitest/browser-webdriverio": "4.1.10",
+ "@vitest/coverage-istanbul": "4.1.10",
+ "@vitest/coverage-v8": "4.1.10",
+ "@vitest/ui": "4.1.10",
+ "happy-dom": "*",
+ "jsdom": "*",
+ "vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/coverage-istanbul": {
+ "optional": true
+ },
+ "@vitest/coverage-v8": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ },
+ "vite": {
+ "optional": false
+ }
+ }
+ },
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
+ "node_modules/w3c-xmlserializer": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
+ "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "xml-name-validator": "^5.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/webidl-conversions": {
+ "version": "8.0.1",
+ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
+ "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-mimetype": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
+ "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/whatwg-url": {
+ "version": "16.0.1",
+ "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
+ "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@exodus/bytes": "^1.11.0",
+ "tr46": "^6.0.0",
+ "webidl-conversions": "^8.0.1"
+ },
+ "engines": {
+ "node": "^20.19.0 || ^22.12.0 || >=24.0.0"
+ }
+ },
+ "node_modules/which": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
+ "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "isexe": "^2.0.0"
+ },
+ "bin": {
+ "node-which": "bin/node-which"
+ },
+ "engines": {
+ "node": ">= 8"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/word-wrap": {
+ "version": "1.2.5",
+ "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+ "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/xml-name-validator": {
+ "version": "5.0.0",
+ "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
+ "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/xmlchars": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
+ "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/yocto-queue": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+ "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
+ "node_modules/zod-validation-error": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz",
+ "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "peerDependencies": {
+ "zod": "^3.25.0 || ^4.0.0"
+ }
+ }
+ }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..9105b5e
--- /dev/null
+++ b/package.json
@@ -0,0 +1,81 @@
+{
+ "name": "regex-tools",
+ "version": "0.1.0",
+ "description": "Develop, explain, test and apply regular expressions locally in the browser.",
+ "license": "GPL-3.0-or-later",
+ "author": "Albrecht Degering",
+ "repository": {
+ "type": "git",
+ "url": "git+https://git.add-ideas.de/zemion/regex-tools.git"
+ },
+ "homepage": "https://git.add-ideas.de/zemion/regex-tools",
+ "bugs": {
+ "url": "https://git.add-ideas.de/zemion/regex-tools/issues"
+ },
+ "private": true,
+ "type": "module",
+ "engines": {
+ "node": ">=22"
+ },
+ "scripts": {
+ "predev": "npm run manifest:generate",
+ "dev": "vite",
+ "prebuild": "npm run manifest:generate",
+ "build": "tsc -b && vite build",
+ "preview": "vite preview",
+ "typecheck": "tsc -b --pretty false",
+ "lint": "eslint . --max-warnings=0",
+ "format": "prettier --write .",
+ "format:check": "prettier --check .",
+ "test": "vitest run",
+ "test:watch": "vitest",
+ "test:conformance": "vitest run tests/conformance",
+ "test:browser": "playwright test",
+ "engines:build": "node scripts/build-engines.mjs",
+ "engines:verify": "node scripts/verify-engine-assets.mjs",
+ "manifest:generate": "node scripts/generate-toolbox-manifest.mjs",
+ "manifest:check": "node scripts/generate-toolbox-manifest.mjs --check",
+ "toolbox:check": "toolbox-check dist",
+ "package:release": "node scripts/package-release.mjs",
+ "checksum:release": "node scripts/checksum-release.mjs",
+ "check": "npm run manifest:check && npm run typecheck && npm run lint && npm run format:check && npm run test && npm run build && npm run engines:verify && npm run toolbox:check",
+ "release:artifact": "npm run check && npm run test:browser && npm run package:release"
+ },
+ "dependencies": {
+ "@add-ideas/toolbox-contract": "0.2.2",
+ "@add-ideas/toolbox-shell-react": "0.2.2",
+ "@codemirror/commands": "6.10.4",
+ "@codemirror/language": "6.12.4",
+ "@codemirror/search": "6.7.1",
+ "@codemirror/state": "6.7.1",
+ "@codemirror/view": "6.43.6",
+ "@eslint-community/regexpp": "4.12.2",
+ "@lezer/highlight": "1.2.3",
+ "react": "19.2.7",
+ "react-dom": "19.2.7"
+ },
+ "devDependencies": {
+ "@add-ideas/toolbox-testkit": "0.2.2",
+ "@eslint/js": "10.0.1",
+ "@playwright/test": "1.61.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "16.3.2",
+ "@testing-library/user-event": "14.6.1",
+ "@types/node": "25.9.5",
+ "@types/react": "19.2.17",
+ "@types/react-dom": "19.2.3",
+ "@vitejs/plugin-react": "6.0.3",
+ "eslint": "10.7.0",
+ "eslint-plugin-react-hooks": "7.1.1",
+ "eslint-plugin-react-refresh": "0.5.2",
+ "fflate": "0.8.3",
+ "globals": "17.7.0",
+ "jsdom": "29.1.1",
+ "prettier": "3.9.5",
+ "typescript": "6.0.3",
+ "typescript-eslint": "8.64.0",
+ "vite": "8.1.5",
+ "vitest": "4.1.10"
+ },
+ "packageManager": "npm@11.17.0"
+}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..8d5c452
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,25 @@
+import { defineConfig, devices } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests/browser",
+ fullyParallel: false,
+ timeout: 120_000,
+ expect: { timeout: 10_000 },
+ reporter: [["list"]],
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ trace: "retain-on-failure",
+ },
+ webServer: {
+ command: "npm run build && node scripts/serve-test.mjs",
+ url: "http://127.0.0.1:4173",
+ reuseExistingServer: !process.env.CI,
+ timeout: 180_000,
+ },
+ projects: [
+ {
+ name: "chromium",
+ use: { ...devices["Desktop Chrome"] },
+ },
+ ],
+});
diff --git a/public/engines/README.md b/public/engines/README.md
new file mode 100644
index 0000000..a8ecff3
--- /dev/null
+++ b/public/engines/README.md
@@ -0,0 +1,7 @@
+# Engine assets
+
+Regex Tools 0.1.0 executes ECMAScript through the browser's native `RegExp`
+engine. It therefore ships no external engine binary or WebAssembly pack.
+
+Future flavour packs will be built from pinned, documented open-source
+revisions and placed in versioned subdirectories here.
diff --git a/public/favicon.svg b/public/favicon.svg
new file mode 100644
index 0000000..d16e598
--- /dev/null
+++ b/public/favicon.svg
@@ -0,0 +1,7 @@
+
+ Regex Tools
+
+
+
+
+
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
new file mode 100644
index 0000000..2322940
--- /dev/null
+++ b/public/toolbox-app.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
+ "schemaVersion": 1,
+ "id": "de.add-ideas.regex-tools",
+ "name": "Regex Tools",
+ "version": "0.1.0",
+ "description": "Develop, explain, test and apply regular expressions locally in the browser.",
+ "entry": "./",
+ "icon": "./favicon.svg",
+ "categories": ["developer", "text", "regex"],
+ "tags": ["regular-expression", "match", "replace", "explain", "test"],
+ "integration": {
+ "contextVersion": 1,
+ "launchModes": ["navigate", "new-tab"],
+ "embedding": "unsupported"
+ },
+ "requirements": {
+ "secureContext": false,
+ "workers": true,
+ "indexedDb": true,
+ "crossOriginIsolated": false,
+ "topLevelContext": false
+ },
+ "privacy": {
+ "processing": "local",
+ "fileUploads": false,
+ "telemetry": false
+ },
+ "source": {
+ "repository": "https://git.add-ideas.de/zemion/regex-tools",
+ "license": "GPL-3.0-or-later"
+ },
+ "actions": [
+ {
+ "id": "source",
+ "label": "Source",
+ "url": "https://git.add-ideas.de/zemion/regex-tools"
+ }
+ ]
+}
diff --git a/scripts/build-engines.mjs b/scripts/build-engines.mjs
new file mode 100644
index 0000000..b254aaf
--- /dev/null
+++ b/scripts/build-engines.mjs
@@ -0,0 +1,24 @@
+import { readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const packageJson = JSON.parse(
+ await readFile(path.join(root, "package.json"), "utf8"),
+);
+const engineDirectory = path.join(root, "public", "engines");
+const entries = (await readdir(engineDirectory)).sort();
+
+if (
+ packageJson.version !== "0.1.0" ||
+ entries.length !== 1 ||
+ entries[0] !== "README.md"
+) {
+ throw new Error(
+ "The ECMAScript release must contain only the documented native-browser engine placeholder.",
+ );
+}
+
+console.log(
+ "Regex Tools 0.1.0 uses the native browser RegExp engine; no external engine pack needs building.",
+);
diff --git a/scripts/checksum-release.mjs b/scripts/checksum-release.mjs
new file mode 100644
index 0000000..5262d19
--- /dev/null
+++ b/scripts/checksum-release.mjs
@@ -0,0 +1,12 @@
+import { createHash } from "node:crypto";
+
+export function sha256(data) {
+ return createHash("sha256").update(data).digest("hex");
+}
+
+export function checksumLine(digest, fileName) {
+ if (!/^[a-f0-9]{64}$/u.test(digest) || /[\/\r\n]/u.test(fileName)) {
+ throw new Error("Invalid checksum output");
+ }
+ return `${digest} ${fileName}\n`;
+}
diff --git a/scripts/generate-toolbox-manifest.mjs b/scripts/generate-toolbox-manifest.mjs
new file mode 100644
index 0000000..d677ac0
--- /dev/null
+++ b/scripts/generate-toolbox-manifest.mjs
@@ -0,0 +1,42 @@
+import { readFile, writeFile } from "node:fs/promises";
+import { dirname, join, relative } from "node:path";
+import { fileURLToPath } from "node:url";
+import { format } from "prettier";
+
+const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+const sourcePath = join(root, "src", "toolbox", "manifest.source.json");
+const outputPath = join(root, "public", "toolbox-app.json");
+const packagePath = join(root, "package.json");
+const checkOnly = process.argv.includes("--check");
+
+const source = JSON.parse(await readFile(sourcePath, "utf8"));
+const packageJson = JSON.parse(await readFile(packagePath, "utf8"));
+
+if (source.version !== packageJson.version) {
+ throw new Error(
+ `Manifest version ${source.version} differs from package version ${packageJson.version}`,
+ );
+}
+
+if (
+ source.source?.repository !== "https://git.add-ideas.de/zemion/regex-tools" ||
+ source.source?.license !== "GPL-3.0-or-later"
+) {
+ throw new Error("Manifest source identity is incomplete or inconsistent");
+}
+
+const serialized = await format(JSON.stringify(source), {
+ filepath: outputPath,
+});
+if (checkOnly) {
+ const current = await readFile(outputPath, "utf8").catch(() => "");
+ if (current !== serialized) {
+ throw new Error(
+ `${relative(root, outputPath)} is stale; run npm run manifest:generate`,
+ );
+ }
+ console.log("Toolbox manifest is synchronized");
+} else {
+ await writeFile(outputPath, serialized);
+ console.log(`Generated ${relative(root, outputPath)}`);
+}
diff --git a/scripts/package-release.mjs b/scripts/package-release.mjs
new file mode 100644
index 0000000..e3d62aa
--- /dev/null
+++ b/scripts/package-release.mjs
@@ -0,0 +1,526 @@
+import {
+ lstat,
+ mkdir,
+ mkdtemp,
+ readFile,
+ readdir,
+ rename,
+ rm,
+ writeFile,
+} from "node:fs/promises";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { fileURLToPath, pathToFileURL } from "node:url";
+import { zipSync } from "fflate";
+import { checksumLine, sha256 } from "./checksum-release.mjs";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const zipEpoch = new Date(1980, 0, 1, 0, 0, 0);
+const gplVersion3TextSha256 =
+ "fb981668c18a279e285fc4d83fba1e836cc84dd4daa73c9697d3cfd2d8aca6e0";
+const requiredRootFiles = [
+ "LICENSE",
+ "README.md",
+ "CHANGELOG.md",
+ "SOURCE.md",
+ "THIRD_PARTY_NOTICES.md",
+];
+
+function compareText(left, right) {
+ return left < right ? -1 : left > right ? 1 : 0;
+}
+
+export function safeArchivePath(fileName) {
+ if (
+ typeof fileName !== "string" ||
+ fileName.length === 0 ||
+ fileName.includes("\0") ||
+ fileName.includes("\\") ||
+ fileName.startsWith("/") ||
+ /^[A-Za-z]:\//u.test(fileName)
+ ) {
+ throw new Error(`Invalid release path: ${String(fileName)}`);
+ }
+ const parts = fileName.split("/");
+ if (
+ parts.some(
+ (component) =>
+ component === "" || component === "." || component === "..",
+ ) ||
+ path.posix.normalize(fileName) !== fileName
+ ) {
+ throw new Error(`Non-canonical release path: ${fileName}`);
+ }
+ return fileName;
+}
+
+async function collectDirectory(directory, prefix = "") {
+ const result = [];
+ const visit = async (current) => {
+ const children = await readdir(current, { withFileTypes: true });
+ children.sort((left, right) => compareText(left.name, right.name));
+ for (const child of children) {
+ const absolute = path.join(current, child.name);
+ const details = await lstat(absolute);
+ if (details.isSymbolicLink()) {
+ throw new Error(`Release input contains a symlink: ${absolute}`);
+ }
+ if (details.isDirectory()) {
+ await visit(absolute);
+ } else if (details.isFile()) {
+ const relative = path
+ .relative(directory, absolute)
+ .split(path.sep)
+ .join("/");
+ result.push({
+ name: safeArchivePath(
+ prefix ? path.posix.join(prefix, relative) : relative,
+ ),
+ data: await readFile(absolute),
+ });
+ } else {
+ throw new Error(`Release input contains a special file: ${absolute}`);
+ }
+ }
+ };
+ await visit(directory);
+ return result;
+}
+
+function safePackageDirectory(name, version) {
+ const normalized = `${name.replace(/^@/u, "").replaceAll("/", "__")}-${
+ version
+ }`;
+ if (!/^[A-Za-z0-9._@+-]+$/u.test(normalized)) {
+ throw new Error(`Unsafe package identity: ${name}@${version}`);
+ }
+ return normalized;
+}
+
+async function collectRuntimePackages(packageJson) {
+ const packages = new Map();
+ const visit = async (name, fromDirectory) => {
+ const resolver = createRequire(path.join(fromDirectory, "package.json"));
+ let packagePath;
+ try {
+ packagePath = resolver.resolve(`${name}/package.json`);
+ } catch {
+ const entry = resolver.resolve(name);
+ let directory = path.dirname(entry);
+ while (directory !== path.dirname(directory)) {
+ const candidate = path.join(directory, "package.json");
+ const details = await lstat(candidate).catch(() => null);
+ if (details?.isFile()) {
+ const candidateJson = JSON.parse(await readFile(candidate, "utf8"));
+ if (candidateJson.name === name) {
+ packagePath = candidate;
+ break;
+ }
+ }
+ directory = path.dirname(directory);
+ }
+ }
+ if (!packagePath) {
+ throw new Error(`Could not resolve runtime package ${name}.`);
+ }
+ const details = JSON.parse(await readFile(packagePath, "utf8"));
+ const key = `${details.name}@${details.version}`;
+ if (packages.has(key)) return;
+ const directory = path.dirname(packagePath);
+ packages.set(key, { directory, details });
+ for (const dependency of Object.keys(details.dependencies ?? {}).sort()) {
+ await visit(dependency, directory);
+ }
+ };
+ for (const dependency of Object.keys(packageJson.dependencies ?? {}).sort()) {
+ await visit(dependency, root);
+ }
+ return [...packages.values()].sort((left, right) =>
+ compareText(
+ `${left.details.name}@${left.details.version}`,
+ `${right.details.name}@${right.details.version}`,
+ ),
+ );
+}
+
+async function collectRuntimeLicenceEntries(packageJson) {
+ const packages = await collectRuntimePackages(packageJson);
+ const entries = [];
+ const rows = [
+ "# Runtime npm dependency licences",
+ "",
+ "| Package | Version | Licence | Repository |",
+ "| --- | --- | --- | --- |",
+ ];
+ for (const runtimePackage of packages) {
+ const { details, directory } = runtimePackage;
+ const legalFiles = (await readdir(directory, { withFileTypes: true }))
+ .filter(
+ (entry) =>
+ entry.isFile() &&
+ /^(?:licen[cs]e|copying|notice)(?:\.|$)/iu.test(entry.name),
+ )
+ .sort((left, right) => compareText(left.name, right.name));
+ if (legalFiles.length === 0) {
+ throw new Error(
+ `Runtime package ${details.name}@${details.version} has no licence file.`,
+ );
+ }
+ const packageDirectory = safePackageDirectory(
+ details.name,
+ details.version,
+ );
+ for (const legalFile of legalFiles) {
+ entries.push({
+ name: safeArchivePath(
+ `LICENSES/npm/${packageDirectory}/${legalFile.name}`,
+ ),
+ data: await readFile(path.join(directory, legalFile.name)),
+ });
+ }
+ const repository =
+ typeof details.repository === "string"
+ ? details.repository
+ : (details.repository?.url ?? "");
+ rows.push(
+ `| \`${details.name}\` | ${details.version} | ${
+ details.license ?? "See packaged licence"
+ } | ${String(repository).replaceAll("|", "\\|")} |`,
+ );
+ }
+ entries.push({
+ name: "LICENSES/npm/DEPENDENCIES.md",
+ data: Buffer.from(`${rows.join("\n")}\n`),
+ });
+ return entries;
+}
+
+async function collectBuildGeneratedLicenceEntries() {
+ const resolver = createRequire(path.join(root, "package.json"));
+ const dependencies = [
+ {
+ name: "rolldown",
+ version: "1.1.5",
+ license: "MIT",
+ legalFile: "LICENSE",
+ },
+ {
+ name: "vite",
+ version: "8.1.5",
+ license: "MIT",
+ legalFile: "LICENSE.md",
+ },
+ ];
+ const entries = [];
+ for (const dependency of dependencies) {
+ const packagePath = resolver.resolve(`${dependency.name}/package.json`);
+ const details = JSON.parse(await readFile(packagePath, "utf8"));
+ if (
+ details.name !== dependency.name ||
+ details.version !== dependency.version ||
+ details.license !== dependency.license
+ ) {
+ throw new Error(
+ `Build dependency identity drifted: expected ${dependency.name}@${dependency.version} (${dependency.license}).`,
+ );
+ }
+ const legalPath = path.join(
+ path.dirname(packagePath),
+ dependency.legalFile,
+ );
+ const legalDetails = await lstat(legalPath);
+ if (legalDetails.isSymbolicLink() || !legalDetails.isFile()) {
+ throw new Error(
+ `Build dependency legal file is not a regular file: ${legalPath}.`,
+ );
+ }
+ entries.push({
+ name: safeArchivePath(
+ `LICENSES/build/${safePackageDirectory(
+ details.name,
+ details.version,
+ )}/${dependency.legalFile}`,
+ ),
+ data: await readFile(legalPath),
+ });
+ }
+ return entries;
+}
+
+function scanEntry(entry) {
+ if (entry.name.endsWith(".map")) {
+ throw new Error(`Source map is not approved for release: ${entry.name}`);
+ }
+ const components = entry.name.toLowerCase().split("/");
+ if (
+ components.some((component) =>
+ [
+ ".env",
+ ".npmrc",
+ ".git",
+ "credentials",
+ "id_rsa",
+ "id_ed25519",
+ ].includes(component),
+ ) ||
+ /\.(?:key|pem|p12|pfx)$/iu.test(entry.name)
+ ) {
+ throw new Error(`Credential-like release path: ${entry.name}`);
+ }
+ if (!/\.(?:css|html|js|json|md|svg|txt)$/iu.test(entry.name)) return;
+ const text = entry.data.toString("utf8");
+ if (/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/u.test(text)) {
+ throw new Error(`Private key material detected in ${entry.name}`);
+ }
+ if (/https?:\/\/[^/@\s]+:[^/@\s]+@/iu.test(text)) {
+ throw new Error(`Credential-bearing URL detected in ${entry.name}`);
+ }
+ if (
+ /(?:^|[\s"'`])(?:\/home\/|\/mnt\/|\/Users\/|[A-Za-z]:\\Users\\)/mu.test(
+ text,
+ )
+ ) {
+ throw new Error(`Local absolute path detected in ${entry.name}`);
+ }
+}
+
+export function createDeterministicZip(inputEntries) {
+ const entries = inputEntries
+ .map((entry) => ({
+ name: safeArchivePath(entry.name),
+ data: Buffer.from(entry.data),
+ }))
+ .sort((left, right) => compareText(left.name, right.name));
+ const seen = new Set();
+ const zippable = Object.create(null);
+ let totalBytes = 0;
+ for (const entry of entries) {
+ if (seen.has(entry.name)) {
+ throw new Error(`Duplicate release path: ${entry.name}`);
+ }
+ seen.add(entry.name);
+ totalBytes += entry.data.byteLength;
+ if (entry.data.byteLength > 128 * 1024 * 1024) {
+ throw new Error(`Release entry is too large: ${entry.name}`);
+ }
+ if (totalBytes > 512 * 1024 * 1024) {
+ throw new Error("Release exceeds the 512 MiB uncompressed limit.");
+ }
+ scanEntry(entry);
+ zippable[entry.name] = [
+ new Uint8Array(entry.data),
+ {
+ attrs: (0o100644 << 16) >>> 0,
+ level: 9,
+ mtime: zipEpoch,
+ os: 3,
+ },
+ ];
+ }
+ return Buffer.from(
+ zipSync(zippable, {
+ attrs: (0o100644 << 16) >>> 0,
+ level: 9,
+ mtime: zipEpoch,
+ os: 3,
+ }),
+ );
+}
+
+async function collectReleaseEntries() {
+ const packageJson = JSON.parse(
+ await readFile(path.join(root, "package.json"), "utf8"),
+ );
+ if (
+ packageJson.version !== "0.1.0" ||
+ packageJson.license !== "GPL-3.0-or-later" ||
+ packageJson.repository?.url !==
+ "git+https://git.add-ideas.de/zemion/regex-tools.git"
+ ) {
+ throw new Error("Package version, licence or source identity drifted.");
+ }
+ const licence = await readFile(path.join(root, "LICENSE"), "utf8");
+ if (
+ !licence.includes("GNU GENERAL PUBLIC LICENSE") ||
+ !licence.includes("Version 3, 29 June 2007") ||
+ !licence.includes("END OF TERMS AND CONDITIONS") ||
+ sha256(Buffer.from(licence)) !== gplVersion3TextSha256
+ ) {
+ throw new Error(
+ "Root LICENSE is not the exact complete GPL version 3 text.",
+ );
+ }
+ const entries = await collectDirectory(path.join(root, "dist"));
+ entries.push(
+ ...(await collectDirectory(path.join(root, "LICENSES"), "LICENSES")),
+ );
+ entries.push(...(await collectRuntimeLicenceEntries(packageJson)));
+ entries.push(...(await collectBuildGeneratedLicenceEntries()));
+ for (const fileName of requiredRootFiles) {
+ entries.push({
+ name: fileName,
+ data: await readFile(path.join(root, fileName)),
+ });
+ }
+ const names = new Set(entries.map((entry) => entry.name));
+ for (const required of [
+ "index.html",
+ "toolbox-app.json",
+ "favicon.svg",
+ "LICENSE",
+ "CHANGELOG.md",
+ "SOURCE.md",
+ "THIRD_PARTY_NOTICES.md",
+ "LICENSES/build/rolldown-1.1.5/LICENSE",
+ "LICENSES/build/vite-8.1.5/LICENSE.md",
+ ]) {
+ if (!names.has(required)) {
+ throw new Error(`Required release file is missing: ${required}`);
+ }
+ }
+ if (![...names].some((name) => name.startsWith("assets/"))) {
+ throw new Error("Release has no production assets.");
+ }
+ if (![...names].some((name) => name.startsWith("LICENSES/"))) {
+ throw new Error("Release has no third-party licence directory.");
+ }
+ const manifest = JSON.parse(
+ entries
+ .find((entry) => entry.name === "toolbox-app.json")
+ .data.toString("utf8"),
+ );
+ if (
+ manifest.id !== "de.add-ideas.regex-tools" ||
+ manifest.version !== packageJson.version ||
+ manifest.source?.repository !==
+ "https://git.add-ideas.de/zemion/regex-tools" ||
+ manifest.source?.license !== "GPL-3.0-or-later"
+ ) {
+ throw new Error("Packaged manifest identity is inconsistent.");
+ }
+ const indexHtml = entries
+ .find((entry) => entry.name === "index.html")
+ .data.toString("utf8");
+ if (/(?:src|href)\s*=\s*["']\/(?!\/)/iu.test(indexHtml)) {
+ throw new Error("index.html contains a root-absolute asset path.");
+ }
+ return { entries, packageJson };
+}
+
+export function parseReleaseArguments(argumentsList) {
+ const allowed = new Set(["--force"]);
+ const unknown = argumentsList.filter((argument) => !allowed.has(argument));
+ if (unknown.length > 0) {
+ throw new Error(`Unknown release argument: ${unknown.join(", ")}`);
+ }
+ return { force: argumentsList.includes("--force") };
+}
+
+async function assertWritableTarget(target, force) {
+ try {
+ const details = await lstat(target);
+ if (details.isSymbolicLink() || !details.isFile()) {
+ throw new Error(`Release target is not a regular file: ${target}`);
+ }
+ if (!force) {
+ throw new Error(
+ `${path.relative(root, target)} exists; pass --force to replace this exact version.`,
+ );
+ }
+ return true;
+ } catch (error) {
+ if (error && typeof error === "object" && error.code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+}
+
+async function publishReleasePair({
+ archive,
+ archivePath,
+ checksum,
+ checksumPath,
+ archiveExists,
+ checksumExists,
+}) {
+ const releaseDirectory = path.dirname(archivePath);
+ const stage = await mkdtemp(
+ path.join(releaseDirectory, ".regex-tools-release-"),
+ );
+ const stagedArchive = path.join(stage, path.basename(archivePath));
+ const stagedChecksum = path.join(stage, path.basename(checksumPath));
+ const previousArchive = path.join(stage, "previous-archive");
+ const previousChecksum = path.join(stage, "previous-checksum");
+ let archiveBackedUp = false;
+ let checksumBackedUp = false;
+ let archivePublished = false;
+ let checksumPublished = false;
+ try {
+ await writeFile(stagedArchive, archive, { flag: "wx", mode: 0o644 });
+ await writeFile(stagedChecksum, checksum, {
+ encoding: "utf8",
+ flag: "wx",
+ mode: 0o644,
+ });
+ if (archiveExists) {
+ await rename(archivePath, previousArchive);
+ archiveBackedUp = true;
+ }
+ if (checksumExists) {
+ await rename(checksumPath, previousChecksum);
+ checksumBackedUp = true;
+ }
+ await rename(stagedArchive, archivePath);
+ archivePublished = true;
+ await rename(stagedChecksum, checksumPath);
+ checksumPublished = true;
+ } catch (error) {
+ if (checksumPublished) await rm(checksumPath, { force: true });
+ if (archivePublished) await rm(archivePath, { force: true });
+ if (checksumBackedUp) await rename(previousChecksum, checksumPath);
+ if (archiveBackedUp) await rename(previousArchive, archivePath);
+ throw error;
+ } finally {
+ await rm(stage, { recursive: true, force: true });
+ }
+}
+
+async function main() {
+ const options = parseReleaseArguments(process.argv.slice(2));
+ const { entries, packageJson } = await collectReleaseEntries();
+ const archive = createDeterministicZip(entries);
+ const fileName = `regex-tools-${packageJson.version}.zip`;
+ const digest = sha256(archive);
+ const releaseDirectory = path.join(root, "release");
+ await mkdir(releaseDirectory, { recursive: true });
+ const releaseDirectoryDetails = await lstat(releaseDirectory);
+ if (
+ releaseDirectoryDetails.isSymbolicLink() ||
+ !releaseDirectoryDetails.isDirectory()
+ ) {
+ throw new Error("release/ must be a real directory.");
+ }
+ const archivePath = path.join(releaseDirectory, fileName);
+ const checksumPath = `${archivePath}.sha256`;
+ const archiveExists = await assertWritableTarget(archivePath, options.force);
+ const checksumExists = await assertWritableTarget(
+ checksumPath,
+ options.force,
+ );
+ await publishReleasePair({
+ archive,
+ archivePath,
+ checksum: checksumLine(digest, fileName),
+ checksumPath,
+ archiveExists,
+ checksumExists,
+ });
+ console.log(
+ `Created release/${fileName} (${archive.byteLength} bytes, SHA-256 ${digest})`,
+ );
+}
+
+if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
+ await main();
+}
diff --git a/scripts/package-release.test.ts b/scripts/package-release.test.ts
new file mode 100644
index 0000000..217b29b
--- /dev/null
+++ b/scripts/package-release.test.ts
@@ -0,0 +1,51 @@
+import { describe, expect, it } from "vitest";
+import {
+ createDeterministicZip,
+ parseReleaseArguments,
+ safeArchivePath,
+} from "./package-release.mjs";
+import { sha256 } from "./checksum-release.mjs";
+
+describe("release packaging", () => {
+ it("creates a deterministic archive independent of input order", () => {
+ const entries = [
+ { name: "index.html", data: Buffer.from("ok
") },
+ { name: "assets/app.js", data: Buffer.from("export {};") },
+ ];
+ expect(sha256(createDeterministicZip(entries))).toBe(
+ sha256(createDeterministicZip([...entries].reverse())),
+ );
+ });
+
+ it.each(["../secret", "/absolute", "C:/absolute", "a\\b", "a/./b", ""])(
+ "rejects unsafe path %j",
+ (candidate) => {
+ expect(() => safeArchivePath(candidate)).toThrow();
+ },
+ );
+
+ it("rejects duplicate paths, source maps and credential-like entries", () => {
+ expect(() =>
+ createDeterministicZip([
+ { name: "index.html", data: Buffer.from("one") },
+ { name: "index.html", data: Buffer.from("two") },
+ ]),
+ ).toThrow("Duplicate release path");
+ expect(() =>
+ createDeterministicZip([
+ { name: "assets/app.js.map", data: Buffer.from("{}") },
+ ]),
+ ).toThrow("Source map");
+ expect(() =>
+ createDeterministicZip([{ name: ".env", data: Buffer.from("TOKEN=x") }]),
+ ).toThrow("Credential-like");
+ });
+
+ it("requires an explicit force flag for exact-version replacement", () => {
+ expect(parseReleaseArguments([])).toEqual({ force: false });
+ expect(parseReleaseArguments(["--force"])).toEqual({ force: true });
+ expect(() => parseReleaseArguments(["--unknown"])).toThrow(
+ "Unknown release argument",
+ );
+ });
+});
diff --git a/scripts/serve-test.mjs b/scripts/serve-test.mjs
new file mode 100644
index 0000000..f888692
--- /dev/null
+++ b/scripts/serve-test.mjs
@@ -0,0 +1,79 @@
+import { createServer } from "node:http";
+import { readFile, stat } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "dist",
+);
+const nestedPrefix = "/deep/nested/regex/";
+const mediaTypes = new Map([
+ [".css", "text/css; charset=utf-8"],
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".json", "application/json; charset=utf-8"],
+ [".svg", "image/svg+xml"],
+ [".wasm", "application/wasm"],
+]);
+const testCatalogue = {
+ schemaVersion: 1,
+ id: "de.add-ideas.regex-tools.browser-test",
+ name: "Regex Tools browser-test Toolbox",
+ home: "./",
+ theme: { mode: "system", brand: "add·ideas" },
+ apps: [{ manifest: "./toolbox-app.json", enabled: true }],
+};
+
+function safeFile(requestPath) {
+ const decoded = decodeURIComponent(requestPath);
+ const relative = decoded.startsWith(nestedPrefix)
+ ? decoded.slice(nestedPrefix.length)
+ : decoded.replace(/^\/+/u, "");
+ const normalized = path.posix.normalize(relative || "index.html");
+ if (
+ normalized === ".." ||
+ normalized.startsWith("../") ||
+ path.isAbsolute(normalized)
+ ) {
+ return null;
+ }
+ return path.join(root, normalized);
+}
+
+const server = createServer(async (request, response) => {
+ try {
+ const url = new URL(request.url ?? "/", "http://127.0.0.1");
+ if (url.pathname === "/toolbox.catalog.json") {
+ response.writeHead(200, {
+ "Content-Type": "application/json; charset=utf-8",
+ "Cache-Control": "no-cache",
+ });
+ response.end(JSON.stringify(testCatalogue));
+ return;
+ }
+ let file = safeFile(url.pathname);
+ if (!file) {
+ response.writeHead(400).end("Bad request");
+ return;
+ }
+ const details = await stat(file).catch(() => null);
+ if (details?.isDirectory()) file = path.join(file, "index.html");
+ const content = await readFile(file);
+ response.writeHead(200, {
+ "Content-Type":
+ mediaTypes.get(path.extname(file)) ?? "application/octet-stream",
+ "Cache-Control": "no-cache",
+ "Cross-Origin-Resource-Policy": "same-origin",
+ });
+ response.end(content);
+ } catch {
+ response.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
+ response.end("Not found");
+ }
+});
+
+server.listen(4173, "127.0.0.1", () => {
+ console.log("Test static server listening on http://127.0.0.1:4173");
+});
diff --git a/scripts/verify-engine-assets.mjs b/scripts/verify-engine-assets.mjs
new file mode 100644
index 0000000..363ee04
--- /dev/null
+++ b/scripts/verify-engine-assets.mjs
@@ -0,0 +1,34 @@
+import { lstat, readFile, readdir } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const engineDirectory = path.join(root, "dist", "engines");
+const details = await lstat(engineDirectory);
+if (details.isSymbolicLink() || !details.isDirectory()) {
+ throw new Error("dist/engines must be a real directory.");
+}
+
+const entries = (await readdir(engineDirectory, { withFileTypes: true })).sort(
+ (left, right) =>
+ left.name < right.name ? -1 : left.name > right.name ? 1 : 0,
+);
+if (
+ entries.length !== 1 ||
+ !entries[0]?.isFile() ||
+ entries[0].name !== "README.md"
+) {
+ throw new Error(
+ "Regex Tools 0.1.0 must not ship an undeclared external engine pack.",
+ );
+}
+
+const notice = await readFile(path.join(engineDirectory, "README.md"), "utf8");
+if (
+ !notice.includes("native `RegExp`") ||
+ !notice.includes("no external engine")
+) {
+ throw new Error("The engine asset notice does not describe this release.");
+}
+
+console.log("Verified ECMAScript-only engine assets (no WebAssembly pack).");
diff --git a/src/App.tsx b/src/App.tsx
new file mode 100644
index 0000000..51d4669
--- /dev/null
+++ b/src/App.tsx
@@ -0,0 +1,42 @@
+import { lazy, Suspense, useState } from "react";
+import { AppShell } from "@add-ideas/toolbox-shell-react";
+import "@add-ideas/toolbox-shell-react/styles.css";
+import "./styles.css";
+import { manifest } from "./toolbox/manifest";
+import { AppErrorBoundary } from "./app/AppErrorBoundary";
+import { HelpDialog } from "./components/HelpDialog";
+
+const Workbench = lazy(async () => {
+ const module = await import("./components/Workbench");
+ return { default: module.Workbench };
+});
+
+export function App() {
+ const [helpOpen, setHelpOpen] = useState(false);
+ return (
+
+ setHelpOpen(true) }}
+ onContextError={(error) => {
+ console.warn(
+ "Toolbox context unavailable; continuing standalone.",
+ error,
+ );
+ }}
+ >
+
+ Preparing the local regex workbench…
+
+ }
+ >
+
+
+
+ setHelpOpen(false)} />
+
+ );
+}
diff --git a/src/app/AppErrorBoundary.tsx b/src/app/AppErrorBoundary.tsx
new file mode 100644
index 0000000..ee2ebac
--- /dev/null
+++ b/src/app/AppErrorBoundary.tsx
@@ -0,0 +1,37 @@
+import { Component, type ErrorInfo, type ReactNode } from "react";
+
+export class AppErrorBoundary extends Component<
+ { readonly children: ReactNode },
+ { readonly error: Error | null }
+> {
+ state = { error: null as Error | null };
+
+ static getDerivedStateFromError(error: Error) {
+ return { error };
+ }
+
+ componentDidCatch(error: Error, info: ErrorInfo): void {
+ console.error("Regex Tools render failure", error, info);
+ }
+
+ render() {
+ if (this.state.error) {
+ return (
+
+ Regex Tools could not render
+ {this.state.error.message}
+ {
+ this.setState({ error: null });
+ window.location.reload();
+ }}
+ >
+ Reload
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/browser/clipboard.test.ts b/src/browser/clipboard.test.ts
new file mode 100644
index 0000000..d17f9b0
--- /dev/null
+++ b/src/browser/clipboard.test.ts
@@ -0,0 +1,52 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { writeClipboardText } from "./clipboard";
+
+afterEach(() => {
+ vi.restoreAllMocks();
+ Reflect.deleteProperty(document, "execCommand");
+});
+
+describe("clipboard writer", () => {
+ it("uses the Clipboard API when available", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+
+ await writeClipboardText("complete value");
+
+ expect(writeText).toHaveBeenCalledWith("complete value");
+ });
+
+ it("falls back after a secure-context or permission failure", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText: vi.fn().mockRejectedValue(new Error("denied")) },
+ });
+ const execCommand = vi.fn().mockReturnValue(true);
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: execCommand,
+ });
+
+ await writeClipboardText("fallback value");
+
+ expect(execCommand).toHaveBeenCalledWith("copy");
+ });
+
+ it("reports when neither copy mechanism is available", async () => {
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: undefined,
+ });
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: vi.fn().mockReturnValue(false),
+ });
+
+ await expect(writeClipboardText("value")).rejects.toThrow(
+ /Clipboard access is unavailable/u,
+ );
+ });
+});
diff --git a/src/browser/clipboard.ts b/src/browser/clipboard.ts
new file mode 100644
index 0000000..f698595
--- /dev/null
+++ b/src/browser/clipboard.ts
@@ -0,0 +1,34 @@
+function legacyCopy(text: string): boolean {
+ if (typeof document.execCommand !== "function") return false;
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.readOnly = true;
+ textarea.setAttribute("aria-hidden", "true");
+ textarea.style.position = "fixed";
+ textarea.style.inset = "0 auto auto -10000px";
+ textarea.style.opacity = "0";
+ document.body.append(textarea);
+ textarea.select();
+ textarea.setSelectionRange(0, textarea.value.length);
+ try {
+ return document.execCommand("copy");
+ } finally {
+ textarea.remove();
+ }
+}
+
+export async function writeClipboardText(text: string): Promise {
+ try {
+ if (navigator.clipboard?.writeText) {
+ await navigator.clipboard.writeText(text);
+ return;
+ }
+ } catch {
+ // A permission or secure-context failure may still permit the local fallback.
+ }
+ if (!legacyCopy(text)) {
+ throw new Error(
+ "Clipboard access is unavailable in this browser context. Select and copy the value manually.",
+ );
+ }
+}
diff --git a/src/components/CapabilityPanel.test.tsx b/src/components/CapabilityPanel.test.tsx
new file mode 100644
index 0000000..431bf72
--- /dev/null
+++ b/src/components/CapabilityPanel.test.tsx
@@ -0,0 +1,88 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import type { RegexExecutionResult } from "../regex/model/match";
+import type { RegexSyntaxResult } from "../regex/model/syntax";
+import { CapabilityPanel } from "./CapabilityPanel";
+
+const syntax: RegexSyntaxResult = {
+ accepted: true,
+ root: {
+ id: "root",
+ kind: "pattern",
+ range: { startUtf16: 0, endUtf16: 1 },
+ raw: "a",
+ explanation: "Pattern",
+ children: [],
+ properties: {
+ zeroWidth: false,
+ nullable: false,
+ minimumLength: 1,
+ maximumLength: 1,
+ consumesInput: true,
+ },
+ support: { flavour: "ecmascript", status: "supported", notes: [] },
+ provenance: {
+ provider: "fixture",
+ providerVersion: "9",
+ source: "parsed",
+ },
+ },
+ tokens: [],
+ captures: [],
+ diagnostics: [],
+ provider: { id: "fixture-provider", version: "9" },
+ coverage: {
+ status: "partial",
+ summary: "Fixture coverage summary",
+ unsupportedConstructs: ["fixture"],
+ },
+};
+
+const execution: RegexExecutionResult = {
+ accepted: true,
+ engine: {
+ flavour: "ecmascript",
+ adapterVersion: "1",
+ engineName: "Fixture engine",
+ engineVersion: "123",
+ offsetUnit: "utf8-byte",
+ capabilities: {
+ compilation: true,
+ matching: true,
+ replacement: false,
+ namedCaptures: false,
+ captureHistory: false,
+ actualTrace: false,
+ benchmark: false,
+ },
+ },
+ flags: {
+ userFlags: "",
+ effectiveFlags: "d",
+ internallyAddedIndicesFlag: true,
+ internallyAddedGlobalFlag: false,
+ },
+ matches: [],
+ diagnostics: [],
+ elapsedMs: 1,
+ truncated: false,
+};
+
+describe("CapabilityPanel", () => {
+ it("renders the current provider and engine metadata without static drift", () => {
+ render( );
+
+ expect(screen.getByText("fixture-provider 9")).toBeInTheDocument();
+ expect(
+ screen.getByText("partial: Fixture coverage summary"),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Fixture engine")).toBeInTheDocument();
+ expect(screen.getByText("123")).toBeInTheDocument();
+ expect(screen.getByText("UTF-8 bytes")).toBeInTheDocument();
+
+ const replacement = screen.getByText("Replacement").closest("div");
+ expect(replacement).toHaveTextContent("Unavailable");
+ const compilation = screen.getByText("Compilation").closest("div");
+ expect(compilation).toHaveTextContent("Available");
+ });
+});
diff --git a/src/components/CapabilityPanel.tsx b/src/components/CapabilityPanel.tsx
new file mode 100644
index 0000000..2a4e8eb
--- /dev/null
+++ b/src/components/CapabilityPanel.tsx
@@ -0,0 +1,122 @@
+import { REGEXPP_VERSION, SYNTAX_PROFILE } from "../version";
+import type { RegexExecutionResult } from "../regex/model/match";
+import type { RegexSyntaxResult } from "../regex/model/syntax";
+import type { RegexEngineCapabilities } from "../regex/model/flavour";
+
+function capability(
+ capabilities: RegexEngineCapabilities | undefined,
+ key: keyof RegexEngineCapabilities,
+ unavailableDetail?: string,
+): string {
+ if (!capabilities) return "Awaiting first engine result";
+ if (capabilities[key]) return "Available";
+ return unavailableDetail
+ ? `Unavailable — ${unavailableDetail}`
+ : "Unavailable";
+}
+
+function offsetLabel(
+ unit: RegexExecutionResult["engine"]["offsetUnit"] | undefined,
+): string {
+ switch (unit) {
+ case "utf16":
+ return "UTF-16 code units";
+ case "code-point":
+ return "Unicode code points";
+ case "utf8-byte":
+ return "UTF-8 bytes";
+ case "byte":
+ return "Bytes";
+ default:
+ return "Awaiting first engine result";
+ }
+}
+
+export function CapabilityPanel({
+ syntax,
+ execution,
+}: {
+ readonly syntax?: RegexSyntaxResult;
+ readonly execution?: RegexExecutionResult;
+}) {
+ const capabilities = execution?.engine.capabilities;
+ const rows = [
+ [
+ "Flavour",
+ execution?.engine.flavour ??
+ syntax?.root.support.flavour ??
+ "ECMAScript (awaiting workers)",
+ ],
+ [
+ "Syntax provider",
+ syntax
+ ? `${syntax.provider.id} ${syntax.provider.version}`
+ : `regexpp ${REGEXPP_VERSION} (awaiting syntax worker)`,
+ ],
+ ["Syntax profile", SYNTAX_PROFILE],
+ [
+ "Provider coverage",
+ syntax
+ ? `${syntax.coverage.status}: ${syntax.coverage.summary}`
+ : "Awaiting syntax worker result",
+ ],
+ [
+ "Execution engine",
+ execution?.engine.engineName ?? "Awaiting first engine result",
+ ],
+ [
+ "Engine/runtime version",
+ execution?.engine.engineVersion ?? "Shown after first execution",
+ ],
+ ["Native offsets", offsetLabel(execution?.engine.offsetUnit)],
+ ["Compilation", capability(capabilities, "compilation")],
+ ["Matching", capability(capabilities, "matching")],
+ ["Replacement", capability(capabilities, "replacement")],
+ ["Named captures", capability(capabilities, "namedCaptures")],
+ [
+ "Capture history",
+ capability(
+ capabilities,
+ "captureHistory",
+ "the adapter exposes only the final retained capture",
+ ),
+ ],
+ [
+ "Actual execution trace",
+ capability(
+ capabilities,
+ "actualTrace",
+ "the adapter exposes no engine trace",
+ ),
+ ],
+ ["Benchmark", capability(capabilities, "benchmark")],
+ ] as const;
+ return (
+
+
+
+ {rows.map(([term, value]) => (
+
+
{term}
+ {value}
+
+ ))}
+
+
+ Next flavour: official PCRE2 10.47 WebAssembly with actual callout
+ traces. Python, Go, Rust, .NET and Java remain unavailable until their
+ named runtimes pass the same worker, offset, conformance and licensing
+ gates.
+
+
+ );
+}
diff --git a/src/components/CaptureTable.test.tsx b/src/components/CaptureTable.test.tsx
new file mode 100644
index 0000000..669229f
--- /dev/null
+++ b/src/components/CaptureTable.test.tsx
@@ -0,0 +1,236 @@
+import { render, screen, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { CaptureRow } from "../regex/extraction/capture-rows";
+import { CaptureTable } from "./CaptureTable";
+
+const rows: readonly CaptureRow[] = [
+ {
+ id: "row-2-1",
+ matchNumber: 2,
+ groupNumber: 1,
+ groupName: "letter",
+ status: "participated",
+ value: "a",
+ start: 2,
+ end: 3,
+ length: 1,
+ nativeStart: 2,
+ nativeEnd: 3,
+ nativeUnit: "utf16",
+ line: 1,
+ column: 3,
+ },
+ {
+ id: "row-1-1",
+ matchNumber: 1,
+ groupNumber: 1,
+ groupName: "emoji",
+ status: "participated",
+ value: "😀",
+ start: 0,
+ end: 2,
+ length: 2,
+ nativeStart: 0,
+ nativeEnd: 2,
+ nativeUnit: "utf16",
+ line: 1,
+ column: 1,
+ },
+];
+
+afterEach(() => {
+ Reflect.deleteProperty(document, "execCommand");
+});
+
+describe("CaptureTable", () => {
+ it("sorts, exposes Unicode offsets and copies values and rows separately", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ render(
+ undefined} />,
+ );
+
+ const body = screen
+ .getByRole("grid", {
+ name: "Captured matches",
+ })
+ .querySelector("tbody");
+ if (!body) throw new Error("Capture table body is missing");
+ expect(within(body).getAllByRole("row")[0]).toHaveTextContent("1emoji");
+
+ await user.click(screen.getByRole("button", { name: "Ascending" }));
+ const firstDescendingRow = within(body).getAllByRole("row").at(0);
+ if (!firstDescendingRow) throw new Error("Capture row is missing");
+ expect(
+ within(firstDescendingRow).getAllByRole("cell")[0],
+ ).toHaveTextContent("2");
+ expect(
+ within(firstDescendingRow).getAllByRole("cell")[2],
+ ).toHaveTextContent("letter");
+
+ await user.click(screen.getByLabelText("Unicode advanced view"));
+ expect(
+ screen.getByRole("columnheader", { name: "Start (code point)" }),
+ ).toBeInTheDocument();
+ const emojiRow = within(body).getByRole("row", {
+ name: /Match 1, group 1, emoji/u,
+ });
+ expect(emojiRow).toHaveTextContent("U+1F600");
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy value for match 1, group 1",
+ }),
+ );
+ expect(writeText).toHaveBeenLastCalledWith("😀");
+ expect(screen.getByRole("status")).toHaveTextContent(
+ /Copied the complete returned value/u,
+ );
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy row for match 1, group 1",
+ }),
+ );
+ expect(writeText).toHaveBeenLastCalledWith(JSON.stringify(rows[1]));
+ });
+
+ it("moves to a synchronized row page and reports a filtered selection", async () => {
+ const user = userEvent.setup();
+ const pagedRows = Array.from({ length: 201 }, (_, index): CaptureRow => ({
+ id: `row-${index + 1}-1`,
+ matchNumber: index + 1,
+ groupNumber: 1,
+ status: "participated",
+ value: `value-${index + 1}`,
+ start: index,
+ end: index + 1,
+ length: 1,
+ nativeStart: index,
+ nativeEnd: index + 1,
+ nativeUnit: "utf16",
+ line: 1,
+ column: index + 1,
+ }));
+ render(
+ undefined}
+ />,
+ );
+
+ expect(screen.getByText(/Page 2 of 2/u)).toBeInTheDocument();
+ expect(
+ screen.getByRole("row", {
+ name: /Match 201, group 1, participated/u,
+ }),
+ ).toHaveAttribute("aria-selected", "true");
+
+ await user.type(screen.getByLabelText("Filter captures"), "missing");
+ expect(
+ screen.getByText(/synchronized capture row is hidden/u),
+ ).toBeInTheDocument();
+ });
+
+ it("disables capture export for an engine-limited result prefix", () => {
+ render(
+ undefined}
+ />,
+ );
+
+ expect(
+ screen.getByRole("button", { name: "Export all JSON" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByText(/Capture JSON export is disabled/u),
+ ).toBeInTheDocument();
+ });
+
+ it("bounds only the visible capture value while copying the complete value", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+ const value = `${"\n".repeat(600)}tail`;
+ const row = {
+ ...rows[0]!,
+ value,
+ end: value.length,
+ length: value.length,
+ };
+ render(
+ undefined} />,
+ );
+
+ expect(screen.getByRole("cell", { name: /UI preview/u })).toHaveTextContent(
+ `${value.length.toLocaleString()} UTF-16 units total; UI preview`,
+ );
+ expect(
+ screen.getByRole("cell", { name: /UI preview/u }).textContent,
+ ).not.toContain("tail");
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy value for match 2, group 1",
+ }),
+ );
+ expect(writeText).toHaveBeenLastCalledWith(value);
+ });
+
+ it("labels a clipped engine value with the exact range length", () => {
+ const value = "x".repeat(600);
+ const row: CaptureRow = {
+ ...rows[0]!,
+ status: "truncated",
+ value,
+ start: 0,
+ end: 70_000,
+ length: 70_000,
+ };
+ render(
+ undefined} />,
+ );
+
+ expect(
+ screen.getByRole("cell", { name: /engine value prefix/u }),
+ ).toHaveTextContent(
+ /engine value prefix: 600 of 70,000 UTF-16 units; UI shows the first 512/u,
+ );
+ });
+
+ it("reports unavailable clipboard access instead of rejecting silently", async () => {
+ const user = userEvent.setup();
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: undefined,
+ });
+ Object.defineProperty(document, "execCommand", {
+ configurable: true,
+ value: vi.fn().mockReturnValue(false),
+ });
+ render(
+ undefined} />,
+ );
+
+ await user.click(
+ screen.getByRole("button", {
+ name: "Copy value for match 1, group 1",
+ }),
+ );
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ /Clipboard access is unavailable/u,
+ );
+ });
+});
diff --git a/src/components/CaptureTable.tsx b/src/components/CaptureTable.tsx
new file mode 100644
index 0000000..c3f57c6
--- /dev/null
+++ b/src/components/CaptureTable.tsx
@@ -0,0 +1,551 @@
+import { useMemo, useState, type KeyboardEvent } from "react";
+import type { CaptureRow } from "../regex/extraction/capture-rows";
+import { writeClipboardText } from "../browser/clipboard";
+
+const PAGE_SIZE = 200;
+const MAXIMUM_CODE_POINTS_SHOWN = 64;
+const MAXIMUM_VALUE_PREVIEW_UTF16 = 512;
+
+type SortKey =
+ | "matchNumber"
+ | "groupNumber"
+ | "groupName"
+ | "status"
+ | "value"
+ | "start"
+ | "end"
+ | "length"
+ | "line"
+ | "nativeStart";
+
+const SORT_OPTIONS: readonly {
+ readonly value: SortKey;
+ readonly label: string;
+}[] = [
+ { value: "matchNumber", label: "Match" },
+ { value: "groupNumber", label: "Group number" },
+ { value: "groupName", label: "Group name" },
+ { value: "status", label: "Status" },
+ { value: "value", label: "Value" },
+ { value: "start", label: "UTF-16 start" },
+ { value: "end", label: "UTF-16 end" },
+ { value: "length", label: "Length" },
+ { value: "line", label: "Line" },
+ { value: "nativeStart", label: "Native start" },
+];
+
+function visibleWhitespace(value: string): string {
+ return value
+ .replaceAll(" ", "·")
+ .replaceAll("\t", "⇥")
+ .replaceAll("\r", "␍")
+ .replaceAll("\n", "↵\n");
+}
+
+function visibleValue(row: CaptureRow, showWhitespace: boolean): string {
+ const value = row.value;
+ if (value === undefined) return "—";
+ const visible = value.slice(0, MAXIMUM_VALUE_PREVIEW_UTF16);
+ const rendered = showWhitespace ? visibleWhitespace(visible) : visible;
+ const uiTruncated = value.length > MAXIMUM_VALUE_PREVIEW_UTF16;
+ if (row.status === "truncated") {
+ return `${rendered}${uiTruncated ? "…" : ""} (engine value prefix: ${value.length.toLocaleString()} of ${row.length?.toLocaleString() ?? "unknown"} UTF-16 units${uiTruncated ? "; UI shows the first 512" : ""})`;
+ }
+ return uiTruncated
+ ? `${rendered}… (${(row.length ?? value.length).toLocaleString()} UTF-16 units total; UI preview)`
+ : rendered;
+}
+
+function download(text: string, fileName: string, type: string): void {
+ const url = URL.createObjectURL(new Blob([text], { type }));
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = fileName;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+}
+
+function compareValues(
+ left: string | number | undefined,
+ right: string | number | undefined,
+): number {
+ if (left === right) return 0;
+ if (left === undefined) return 1;
+ if (right === undefined) return -1;
+ return typeof left === "number" && typeof right === "number"
+ ? left - right
+ : String(left).localeCompare(String(right));
+}
+
+function codePointOffsets(
+ subject: string,
+ requestedOffsets: readonly number[],
+): ReadonlyMap {
+ const pending = [...new Set(requestedOffsets)]
+ .filter((offset) => offset >= 0 && offset <= subject.length)
+ .sort((left, right) => left - right);
+ const result = new Map();
+ let pendingIndex = 0;
+ let utf16Offset = 0;
+ let codePointOffset = 0;
+
+ while (pendingIndex < pending.length) {
+ const target = pending[pendingIndex];
+ if (target === undefined) break;
+ if (target === utf16Offset) {
+ result.set(target, codePointOffset);
+ pendingIndex += 1;
+ continue;
+ }
+ if (target < utf16Offset || utf16Offset >= subject.length) {
+ pendingIndex += 1;
+ continue;
+ }
+ const first = subject.charCodeAt(utf16Offset);
+ const second = subject.charCodeAt(utf16Offset + 1);
+ const width =
+ first >= 0xd800 && first <= 0xdbff && second >= 0xdc00 && second <= 0xdfff
+ ? 2
+ : 1;
+ utf16Offset += width;
+ codePointOffset += 1;
+ }
+ return result;
+}
+
+function visibleCodePoints(value: string | undefined): string {
+ if (value === undefined) return "—";
+ const codePoints = Array.from(value);
+ const rendered = codePoints
+ .slice(0, MAXIMUM_CODE_POINTS_SHOWN)
+ .map((item) => {
+ const point = item.codePointAt(0);
+ return point === undefined
+ ? "U+?"
+ : `U+${point.toString(16).toUpperCase().padStart(4, "0")}`;
+ });
+ return `${rendered.join(" ")}${
+ codePoints.length > rendered.length
+ ? ` … (${codePoints.length.toLocaleString()} total)`
+ : ""
+ }`;
+}
+
+export function CaptureTable({
+ rows,
+ subject,
+ sourceResultTruncated = false,
+ selectedId,
+ onSelect,
+}: {
+ readonly rows: readonly CaptureRow[];
+ readonly subject: string;
+ readonly sourceResultTruncated?: boolean;
+ readonly selectedId?: string;
+ readonly onSelect: (row: CaptureRow) => void;
+}) {
+ const [filter, setFilter] = useState("");
+ const [page, setPage] = useState(0);
+ const [showWhitespace, setShowWhitespace] = useState(false);
+ const [showAdvanced, setShowAdvanced] = useState(false);
+ const [sortKey, setSortKey] = useState("matchNumber");
+ const [sortDescending, setSortDescending] = useState(false);
+ const [copyNotice, setCopyNotice] = useState<{
+ readonly kind: "success" | "error";
+ readonly message: string;
+ }>();
+ const filtered = useMemo(() => {
+ const needle = filter.toLocaleLowerCase();
+ if (!needle) return rows;
+ return rows.filter((row) =>
+ [
+ row.matchNumber,
+ row.groupNumber,
+ row.groupName ?? "",
+ row.status,
+ row.value ?? "",
+ ]
+ .join(" ")
+ .toLocaleLowerCase()
+ .includes(needle),
+ );
+ }, [filter, rows]);
+ const sorted = useMemo(
+ () =>
+ filtered
+ .map((row, index) => ({ row, index }))
+ .sort((left, right) => {
+ const comparison = compareValues(
+ left.row[sortKey],
+ right.row[sortKey],
+ );
+ return (
+ (sortDescending ? -comparison : comparison) ||
+ left.index - right.index
+ );
+ })
+ .map(({ row }) => row),
+ [filtered, sortDescending, sortKey],
+ );
+ const selectedIndex =
+ selectedId === undefined
+ ? -1
+ : sorted.findIndex((row) => row.id === selectedId);
+ const selectedPage =
+ selectedIndex < 0 ? undefined : Math.floor(selectedIndex / PAGE_SIZE);
+ const selectionPageKey =
+ selectedId === undefined
+ ? undefined
+ : `${selectedId}:${selectedPage ?? -1}`;
+ const [synchronizedSelectionPageKey, setSynchronizedSelectionPageKey] =
+ useState();
+ if (selectionPageKey !== synchronizedSelectionPageKey) {
+ setSynchronizedSelectionPageKey(selectionPageKey);
+ if (selectedPage !== undefined) setPage(selectedPage);
+ }
+ const maximumPage = Math.max(0, Math.ceil(sorted.length / PAGE_SIZE) - 1);
+ const activePage = Math.min(page, maximumPage);
+ const visible = sorted.slice(
+ activePage * PAGE_SIZE,
+ (activePage + 1) * PAGE_SIZE,
+ );
+ const visibleCodePointOffsets = useMemo(
+ () =>
+ showAdvanced
+ ? codePointOffsets(
+ subject,
+ visible.flatMap((row) => [
+ ...(row.start === undefined ? [] : [row.start]),
+ ...(row.end === undefined ? [] : [row.end]),
+ ]),
+ )
+ : new Map(),
+ [showAdvanced, subject, visible],
+ );
+ const selectedFilteredOut =
+ selectedId !== undefined &&
+ rows.some((row) => row.id === selectedId) &&
+ !sorted.some((row) => row.id === selectedId);
+
+ const copyRow = async (row: CaptureRow): Promise => {
+ try {
+ await writeClipboardText(JSON.stringify(row));
+ setCopyNotice({
+ kind: "success",
+ message: `Copied match ${row.matchNumber}, group ${row.groupNumber} row JSON.`,
+ });
+ } catch (error) {
+ setCopyNotice({
+ kind: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+ const copyValue = async (row: CaptureRow): Promise => {
+ if (row.value === undefined) return;
+ try {
+ await writeClipboardText(row.value);
+ setCopyNotice({
+ kind: "success",
+ message:
+ row.status === "truncated"
+ ? `Copied the available engine value prefix for match ${row.matchNumber}, group ${row.groupNumber}.`
+ : `Copied the complete returned value for match ${row.matchNumber}, group ${row.groupNumber}.`,
+ });
+ } catch (error) {
+ setCopyNotice({
+ kind: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ }
+ };
+
+ const moveRowFocus = (
+ event: KeyboardEvent,
+ row: CaptureRow,
+ ) => {
+ if (event.target !== event.currentTarget) return;
+ const body = event.currentTarget.closest("tbody");
+ const rows = body
+ ? Array.from(body.querySelectorAll("tr"))
+ : [];
+ const index = rows.indexOf(event.currentTarget);
+ let target: number;
+
+ switch (event.key) {
+ case "ArrowDown":
+ target = Math.min(rows.length - 1, index + 1);
+ break;
+ case "ArrowUp":
+ target = Math.max(0, index - 1);
+ break;
+ case "Home":
+ target = 0;
+ break;
+ case "End":
+ target = rows.length - 1;
+ break;
+ case "Enter":
+ case " ":
+ event.preventDefault();
+ onSelect(row);
+ return;
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ const next = rows[target];
+ next?.focus();
+ next?.click();
+ };
+
+ return (
+
+
+
+
Compact result view
+
Capture table
+
+
+ {filtered.length} row{filtered.length === 1 ? "" : "s"}
+
+
+
+
+ Filter captures
+ {
+ setFilter(event.target.value);
+ setPage(0);
+ }}
+ />
+
+
+ setShowWhitespace(event.target.checked)}
+ />
+ Show whitespace
+
+
+ Sort captures
+ {
+ setSortKey(event.target.value as SortKey);
+ setPage(0);
+ }}
+ >
+ {SORT_OPTIONS.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+ {
+ setSortDescending((current) => !current);
+ setPage(0);
+ }}
+ >
+ {sortDescending ? "Descending" : "Ascending"}
+
+
+ setShowAdvanced(event.target.checked)}
+ />
+ Unicode advanced view
+
+
+ download(
+ `${JSON.stringify(rows, null, 2)}\n`,
+ "regex-captures.json",
+ "application/json",
+ )
+ }
+ disabled={rows.length === 0 || sourceResultTruncated}
+ >
+ Export all JSON
+
+
+ {sourceResultTruncated ? (
+
+ The engine stopped collecting results at its configured match or
+ capture-row limit. Capture JSON export is disabled because these rows
+ are only a returned prefix.
+
+ ) : null}
+ {selectedFilteredOut ? (
+
+ The synchronized capture row is hidden by the current filter. Clear
+ the filter to show it.
+
+ ) : null}
+ {copyNotice ? (
+
+ {copyNotice.message}
+
+ ) : null}
+
+
+
+
+ Match
+ Group
+ Name
+ Status
+ Value
+ Start (UTF-16)
+ End (UTF-16)
+ Length (UTF-16)
+ Line:column
+ Native start
+ Native end
+ Native unit
+ {showAdvanced ? (
+ <>
+ Start (code point)
+ End (code point)
+ Value code points
+ >
+ ) : null}
+
+ Actions
+
+
+
+
+ {visible.map((row) => (
+ {
+ event.currentTarget.focus();
+ onSelect(row);
+ }}
+ onKeyDown={(event) => moveRowFocus(event, row)}
+ >
+ {row.matchNumber}
+ {row.groupNumber}
+ {row.groupName ?? "—"}
+ {row.status.replaceAll("-", " ")}
+
+ {visibleValue(row, showWhitespace)}
+
+ {row.start ?? "—"}
+ {row.end ?? "—"}
+ {row.length ?? "—"}
+
+ {row.line === undefined ? "—" : `${row.line}:${row.column}`}
+
+ {row.nativeStart ?? "—"}
+ {row.nativeEnd ?? "—"}
+ {row.nativeUnit ?? "—"}
+ {showAdvanced ? (
+ <>
+
+ {row.start === undefined
+ ? "—"
+ : (visibleCodePointOffsets.get(row.start) ??
+ "split surrogate")}
+
+
+ {row.end === undefined
+ ? "—"
+ : (visibleCodePointOffsets.get(row.end) ??
+ "split surrogate")}
+
+
+ {visibleCodePoints(row.value)}
+
+ >
+ ) : null}
+
+
+ {
+ event.stopPropagation();
+ void copyValue(row);
+ }}
+ >
+ V
+
+ {
+ event.stopPropagation();
+ void copyRow(row);
+ }}
+ >
+ ⧉
+
+
+
+
+ ))}
+
+
+
+ {filtered.length > PAGE_SIZE ? (
+
+ setPage((current) => Math.max(0, current - 1))}
+ >
+ Previous
+
+
+ Page {activePage + 1} of {maximumPage + 1}; rendering at most{" "}
+ {PAGE_SIZE} rows
+
+
+ setPage((current) => Math.min(maximumPage, current + 1))
+ }
+ >
+ Next
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/DiagnosticsPanel.tsx b/src/components/DiagnosticsPanel.tsx
new file mode 100644
index 0000000..7c29df5
--- /dev/null
+++ b/src/components/DiagnosticsPanel.tsx
@@ -0,0 +1,42 @@
+import type { RegexDiagnostic } from "../regex/model/diagnostics";
+
+export function DiagnosticsPanel({
+ diagnostics,
+ onSelect,
+}: {
+ readonly diagnostics: readonly RegexDiagnostic[];
+ readonly onSelect: (diagnostic: RegexDiagnostic) => void;
+}) {
+ return (
+
+
+ Diagnostics
+ {diagnostics.length}
+
+ {diagnostics.length === 0 ? (
+ No current diagnostics.
+ ) : (
+
+ {diagnostics.map((diagnostic) => (
+
+ onSelect(diagnostic)}
+ >
+ {diagnostic.severity}
+ {diagnostic.message}
+
+ {diagnostic.source} · {diagnostic.provenance}
+
+
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/ExplanationTree.test.tsx b/src/components/ExplanationTree.test.tsx
new file mode 100644
index 0000000..be60205
--- /dev/null
+++ b/src/components/ExplanationTree.test.tsx
@@ -0,0 +1,94 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { NormalizedRegexNode } from "../regex/model/syntax";
+import { ExplanationTree } from "./ExplanationTree";
+
+function node(
+ id: string,
+ children: readonly NormalizedRegexNode[] = [],
+): NormalizedRegexNode {
+ return {
+ id,
+ kind: "literal",
+ range: { startUtf16: 0, endUtf16: 1 },
+ raw: id,
+ explanation: `Literal ${id}.`,
+ children,
+ properties: {
+ zeroWidth: false,
+ nullable: false,
+ minimumLength: 1,
+ maximumLength: 1,
+ consumesInput: true,
+ },
+ support: {
+ flavour: "ecmascript",
+ status: "supported",
+ notes: [],
+ },
+ provenance: {
+ provider: "test",
+ providerVersion: "1",
+ source: "parsed",
+ },
+ };
+}
+
+describe("ExplanationTree", () => {
+ it("bounds a large preview while retaining a roving-tabindex fallback", () => {
+ const children = Array.from({ length: 2_001 }, (_, index) =>
+ node(`child-${index}`),
+ );
+ const root = node("root", children);
+ const { container } = render(
+ ,
+ );
+
+ expect(screen.getByTestId("explanation-render-limit")).toHaveTextContent(
+ "Showing the first 2,000 of 2,002 normalized syntax nodes.",
+ );
+ expect(screen.getByTestId("explanation-render-limit")).toHaveTextContent(
+ "The selected pattern node is outside this bounded tree preview.",
+ );
+ expect(container.querySelectorAll('[role="treeitem"]')).toHaveLength(2_000);
+ expect(
+ container.querySelectorAll('[role="treeitem"][tabindex="0"]'),
+ ).toHaveLength(1);
+ expect(
+ container.querySelector('[role="treeitem"][tabindex="0"] .tree-token'),
+ ).toHaveTextContent("root");
+ expect(root.children).toHaveLength(2_001);
+ });
+
+ it("bounds descendants when the cutoff occurs below an unchanged ancestor", () => {
+ const nested = node(
+ "nested",
+ Array.from({ length: 2_100 }, (_, index) => node(`child-${index}`)),
+ );
+ const root = node("root", [nested]);
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll('[role="treeitem"]')).toHaveLength(2_000);
+ expect(root.children[0]?.children).toHaveLength(2_100);
+ });
+
+ it("shows normalized semantics, active flags, compatibility and provenance", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("treeitem")).toHaveTextContent(
+ /range: 0…1 UTF-16.*consumes: yes.*empty: no.*length: 1…1.*active flags: gu.*flavour: ecmascript · supported.*compatibility: no parser warning.*risk: not assessed.*parser: test 1 · parsed/u,
+ );
+ });
+});
diff --git a/src/components/ExplanationTree.tsx b/src/components/ExplanationTree.tsx
new file mode 100644
index 0000000..8220ba3
--- /dev/null
+++ b/src/components/ExplanationTree.tsx
@@ -0,0 +1,229 @@
+import { useMemo } from "react";
+import type { NormalizedRegexNode } from "../regex/model/syntax";
+import { handleTreeNavigation } from "./tree-navigation";
+
+const MAXIMUM_RENDERED_EXPLANATION_NODES = 2_000;
+
+export interface ExplanationTreeProps {
+ readonly root?: NormalizedRegexNode;
+ readonly selectedId?: string;
+ readonly flags?: readonly string[];
+ readonly onSelect: (node: NormalizedRegexNode) => void;
+}
+
+function countNodes(root: NormalizedRegexNode): number {
+ let count = 0;
+ const pending = [root];
+ while (pending.length > 0) {
+ const node = pending.pop();
+ if (!node) continue;
+ count += 1;
+ for (const child of node.children) pending.push(child);
+ }
+ return count;
+}
+
+function boundedRoot(
+ root: NormalizedRegexNode,
+ maximum: number,
+): NormalizedRegexNode {
+ let remaining = maximum;
+ const takeNode = (
+ node: NormalizedRegexNode,
+ ): NormalizedRegexNode | undefined => {
+ if (remaining <= 0) return undefined;
+ remaining -= 1;
+ const children: NormalizedRegexNode[] = [];
+ for (const child of node.children) {
+ const visible = takeNode(child);
+ if (!visible) break;
+ children.push(visible);
+ }
+ return { ...node, children };
+ };
+ return takeNode(root) ?? root;
+}
+
+function containsNode(root: NormalizedRegexNode, id: string): boolean {
+ const pending = [root];
+ while (pending.length > 0) {
+ const node = pending.pop();
+ if (!node) continue;
+ if (node.id === id) return true;
+ for (const child of node.children) pending.push(child);
+ }
+ return false;
+}
+
+function lengthLabel(node: NormalizedRegexNode): string {
+ const minimum = node.properties.minimumLength;
+ const maximum = node.properties.maximumLength;
+ if (minimum === undefined && maximum === undefined) return "length unknown";
+ return `${minimum ?? "?"}…${maximum === null ? "∞" : (maximum ?? "?")}`;
+}
+
+function consumesInputLabel(
+ value: NormalizedRegexNode["properties"]["consumesInput"],
+): string {
+ return value === true ? "yes" : value === false ? "no" : value;
+}
+
+function nullableLabel(
+ value: NormalizedRegexNode["properties"]["nullable"],
+): string {
+ return value === true ? "yes" : value === false ? "no" : value;
+}
+
+function ExplanationNode({
+ node,
+ selectedId,
+ onSelect,
+ depth,
+ focusableId,
+ flags,
+}: {
+ readonly node: NormalizedRegexNode;
+ readonly selectedId?: string;
+ readonly onSelect: (node: NormalizedRegexNode) => void;
+ readonly depth: number;
+ readonly focusableId: string;
+ readonly flags: readonly string[];
+}) {
+ const compatibility =
+ node.support.notes.length > 0
+ ? node.support.notes.slice(0, 2).join("; ").slice(0, 240)
+ : "no parser warning";
+ return (
+
+ onSelect(node)}
+ onKeyDown={handleTreeNavigation}
+ >
+ {node.raw || "∅"}
+ {node.explanation}
+
+ {node.capture
+ ? `group ${node.capture.number}${node.capture.name ? ` · ${node.capture.name}` : ""}`
+ : node.quantifier
+ ? `${node.quantifier.minimum}…${node.quantifier.maximum ?? "∞"} · ${node.quantifier.mode}`
+ : node.properties.zeroWidth
+ ? "zero-width"
+ : lengthLabel(node)}
+
+
+
+ range: {node.range.startUtf16}…{node.range.endUtf16} UTF-16
+
+
+ consumes: {consumesInputLabel(node.properties.consumesInput)}
+
+ empty: {nullableLabel(node.properties.nullable)}
+ length: {lengthLabel(node)}
+
+ active flags:{" "}
+ {(node.activeFlags ?? flags).length > 0
+ ? (node.activeFlags ?? flags).join("")
+ : "none"}
+
+
+ flavour: {node.support.flavour} · {node.support.status}
+
+ compatibility: {compatibility}
+ risk: not assessed in this milestone
+
+ parser: {node.provenance.provider} {node.provenance.providerVersion}{" "}
+ · {node.provenance.source}
+
+
+
+ {node.children.length > 0 ? (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function ExplanationTree({
+ root,
+ selectedId,
+ flags = [],
+ onSelect,
+}: ExplanationTreeProps) {
+ const totalNodes = useMemo(() => (root ? countNodes(root) : 0), [root]);
+ const visibleRoot = useMemo(
+ () =>
+ root ? boundedRoot(root, MAXIMUM_RENDERED_EXPLANATION_NODES) : undefined,
+ [root],
+ );
+ const renderedNodes = Math.min(
+ totalNodes,
+ MAXIMUM_RENDERED_EXPLANATION_NODES,
+ );
+ const selectedNodeVisible =
+ selectedId === undefined ||
+ visibleRoot === undefined ||
+ containsNode(visibleRoot, selectedId);
+ const focusableId =
+ selectedId && selectedNodeVisible ? selectedId : (visibleRoot?.id ?? "");
+
+ return (
+
+
+
+
Pattern structure
+
Explanation tree
+
+
+ Deterministic · syntax provider
+
+
+ {totalNodes > renderedNodes ? (
+
+ Tree preview limited. Showing the first{" "}
+ {renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
+ normalized syntax nodes. The complete normalized syntax tree remains
+ available to the application.
+ {!selectedNodeVisible
+ ? " The selected pattern node is outside this bounded tree preview."
+ : ""}
+
+ ) : null}
+ {visibleRoot ? (
+
+ ) : (
+ The syntax worker is preparing the tree.
+ )}
+
+ );
+}
diff --git a/src/components/ExtractionTree.test.tsx b/src/components/ExtractionTree.test.tsx
new file mode 100644
index 0000000..0c48cd3
--- /dev/null
+++ b/src/components/ExtractionTree.test.tsx
@@ -0,0 +1,76 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+import type { ExtractionNode } from "../regex/model/match";
+import { ExtractionTree } from "./ExtractionTree";
+
+function node(
+ id: string,
+ children: readonly ExtractionNode[] = [],
+): ExtractionNode {
+ return {
+ id,
+ kind: "capture",
+ label: id,
+ value: id,
+ status: "participated",
+ children,
+ provenance: "engine-result",
+ };
+}
+
+describe("ExtractionTree", () => {
+ it("bounds descendants when the cutoff occurs below an ancestor", () => {
+ const nested = node(
+ "nested",
+ Array.from({ length: 2_100 }, (_, index) => node(`child-${index}`)),
+ );
+ const roots = [node("root", [nested])];
+ const { container } = render(
+ ,
+ );
+
+ expect(container.querySelectorAll('[role="treeitem"]')).toHaveLength(2_000);
+ expect(roots[0]?.children[0]?.children).toHaveLength(2_100);
+ });
+
+ it("labels UI-only value truncation without changing engine status", () => {
+ const value = "x".repeat(101);
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("treeitem")).toHaveTextContent(
+ /… \(101 UTF-16 units total; UI preview\).*participated/u,
+ );
+ });
+
+ it("distinguishes a bounded engine prefix from the exact capture range", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("treeitem")).toHaveTextContent(
+ /engine value prefix: 101 of 70,000 UTF-16 units; UI shows the first 100.*UTF-16 range: 10…70010.*native range: 10…70010 utf16/u,
+ );
+ });
+});
diff --git a/src/components/ExtractionTree.tsx b/src/components/ExtractionTree.tsx
new file mode 100644
index 0000000..d4ce419
--- /dev/null
+++ b/src/components/ExtractionTree.tsx
@@ -0,0 +1,223 @@
+import { useMemo } from "react";
+import type { ExtractionNode } from "../regex/model/match";
+import { handleTreeNavigation } from "./tree-navigation";
+
+const MAXIMUM_RENDERED_EXTRACTION_NODES = 2_000;
+const MAXIMUM_VALUE_PREVIEW_UTF16 = 100;
+
+export interface ExtractionTreeProps {
+ readonly nodes: readonly ExtractionNode[];
+ readonly selectedId?: string;
+ readonly onSelect: (node: ExtractionNode) => void;
+ readonly repeatedCaptureNumbers: ReadonlySet;
+}
+
+function printable(node: ExtractionNode): string {
+ const value = node.value;
+ if (value === undefined) return "—";
+ if (value === "") return "∅ empty";
+ const visible = value
+ .slice(0, MAXIMUM_VALUE_PREVIEW_UTF16)
+ .replaceAll("\n", "↵")
+ .replaceAll("\r", "␍")
+ .replaceAll("\t", "⇥");
+ const exactLength = node.range
+ ? node.range.endUtf16 - node.range.startUtf16
+ : undefined;
+ const uiTruncated = value.length > MAXIMUM_VALUE_PREVIEW_UTF16;
+ if (node.status === "truncated") {
+ return `${visible}${uiTruncated ? "…" : ""} (engine value prefix: ${value.length.toLocaleString()} of ${exactLength?.toLocaleString() ?? "unknown"} UTF-16 units${uiTruncated ? "; UI shows the first 100" : ""})`;
+ }
+ return uiTruncated
+ ? `${visible}… (${(exactLength ?? value.length).toLocaleString()} UTF-16 units total; UI preview)`
+ : visible;
+}
+
+function countNodes(nodes: readonly ExtractionNode[]): number {
+ let count = 0;
+ const pending = [...nodes];
+ while (pending.length > 0) {
+ const node = pending.pop();
+ if (!node) continue;
+ count += 1;
+ pending.push(...node.children);
+ }
+ return count;
+}
+
+function boundedNodes(
+ nodes: readonly ExtractionNode[],
+ maximum: number,
+): readonly ExtractionNode[] {
+ let remaining = maximum;
+ const takeNode = (node: ExtractionNode): ExtractionNode | undefined => {
+ if (remaining <= 0) return undefined;
+ remaining -= 1;
+ const children: ExtractionNode[] = [];
+ for (const child of node.children) {
+ const visible = takeNode(child);
+ if (!visible) break;
+ children.push(visible);
+ }
+ return { ...node, children };
+ };
+ const visible: ExtractionNode[] = [];
+ for (const node of nodes) {
+ const bounded = takeNode(node);
+ if (!bounded) break;
+ visible.push(bounded);
+ }
+ return visible;
+}
+
+function containsNode(nodes: readonly ExtractionNode[], id: string): boolean {
+ const pending = [...nodes];
+ while (pending.length > 0) {
+ const node = pending.pop();
+ if (!node) continue;
+ if (node.id === id) return true;
+ pending.push(...node.children);
+ }
+ return false;
+}
+
+function ExtractionTreeNode({
+ node,
+ selectedId,
+ onSelect,
+ repeatedCaptureNumbers,
+ depth,
+ focusableId,
+}: {
+ readonly node: ExtractionNode;
+ readonly selectedId?: string;
+ readonly onSelect: (node: ExtractionNode) => void;
+ readonly repeatedCaptureNumbers: ReadonlySet;
+ readonly depth: number;
+ readonly focusableId: string;
+}) {
+ const repeatedFinal =
+ node.groupNumber !== undefined &&
+ repeatedCaptureNumbers.has(node.groupNumber);
+ return (
+
+ onSelect(node)}
+ onKeyDown={handleTreeNavigation}
+ >
+ {node.label}
+ {printable(node)}
+
+ {node.status.replaceAll("-", " ")}
+
+
+
+ UTF-16 range:{" "}
+ {node.range
+ ? `${node.range.startUtf16}…${node.range.endUtf16}`
+ : "unavailable"}
+
+
+ native range:{" "}
+ {node.nativeRange
+ ? `${node.nativeRange.start}…${node.nativeRange.end} ${node.nativeRange.unit}`
+ : "unavailable"}
+
+ provenance: {node.provenance}
+
+
+ {repeatedFinal ? (
+
+ This engine exposes only the final retained capture for this repeated
+ group.
+
+ ) : null}
+ {node.children.length > 0 ? (
+
+ {node.children.map((child) => (
+
+ ))}
+
+ ) : null}
+
+ );
+}
+
+export function ExtractionTree({
+ nodes,
+ selectedId,
+ onSelect,
+ repeatedCaptureNumbers,
+}: ExtractionTreeProps) {
+ const totalNodes = useMemo(() => countNodes(nodes), [nodes]);
+ const visibleNodes = useMemo(
+ () => boundedNodes(nodes, MAXIMUM_RENDERED_EXTRACTION_NODES),
+ [nodes],
+ );
+ const renderedNodes = Math.min(totalNodes, MAXIMUM_RENDERED_EXTRACTION_NODES);
+ const selectedNodeVisible =
+ selectedId === undefined || containsNode(visibleNodes, selectedId);
+ const focusableId =
+ selectedId && selectedNodeVisible
+ ? selectedId
+ : (visibleNodes[0]?.id ?? "");
+ return (
+
+
+
+ Actual engine result
+
+ {totalNodes > renderedNodes ? (
+
+ Tree preview limited. Showing the first{" "}
+ {renderedNodes.toLocaleString()} of {totalNodes.toLocaleString()}{" "}
+ returned result nodes. All returned data remains available to the
+ other result views and exports.
+ {!selectedNodeVisible
+ ? " The selected result is outside this bounded tree preview."
+ : ""}
+
+ ) : null}
+ {nodes.length > 0 ? (
+
+ {visibleNodes.map((node) => (
+
+ ))}
+
+ ) : (
+ No matches to extract.
+ )}
+
+ );
+}
diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx
new file mode 100644
index 0000000..0ad9934
--- /dev/null
+++ b/src/components/HelpDialog.tsx
@@ -0,0 +1,151 @@
+import { useEffect, useRef } from "react";
+import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
+import { manifest } from "../toolbox/manifest";
+
+const releaseSourceUrl = `https://git.add-ideas.de/zemion/regex-tools/src/tag/v${manifest.version}`;
+
+export function HelpDialog({
+ open,
+ onClose,
+}: {
+ readonly open: boolean;
+ readonly onClose: () => void;
+}) {
+ const dialog = useRef(null);
+ useEffect(() => {
+ const element = dialog.current;
+ if (!element) return;
+ if (open && !element.open) element.showModal();
+ if (!open && element.open) element.close();
+ }, [open]);
+ return (
+
+
+
+
+ What runs where
+
+ Syntax parsing and actual ECMAScript execution run in separate,
+ killable browser workers. Patterns, subjects and projects are never
+ uploaded. There is no backend, account, telemetry, CDN dependency,
+ or executable replacement function.
+
+
+
+ Trees and replacement mapping
+
+ The explanation tree is deterministic structure derived from the
+ syntax provider. The extraction tree contains actual browser-engine
+ matches and captures. Replacement mode adds a typed token tree and
+ bounded per-match contribution/range mapping over those actual
+ matches. None is an internal V8, SpiderMonkey or JavaScriptCore
+ execution trace.
+
+
+
+ Timeouts and limits
+
+
+
Live execution
+ {DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs} ms
+
+
+
Manual execution
+ {DEFAULT_REGEX_LIMITS.manualExecutionTimeoutMs} ms
+
+
+
Maximum matches
+ {DEFAULT_REGEX_LIMITS.maximumMatches.toLocaleString()}
+
+
+
Maximum capture rows
+
+ {DEFAULT_REGEX_LIMITS.maximumCaptureRows.toLocaleString()}
+
+
+
+
Maximum capture groups
+
+ {DEFAULT_REGEX_LIMITS.maximumCaptureGroups.toLocaleString()}
+
+
+
+
Replacement template
+
+ {DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()}{" "}
+ UTF-16 units
+
+
+
+
List template
+
+ {DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16.toLocaleString()}{" "}
+ UTF-16 units
+
+
+
+
Unit-test suite wall time
+ 60 seconds
+
+
+
+ A timeout terminates the worker. It means “timed out”, never “no
+ match”. The following run uses a fresh worker.
+
+
+
+ Persistence
+
+ Ad-hoc test text is excluded from local saves and exports by
+ default. Test definitions are deliberate project data. Import is
+ validated and paused until you review and run it.
+
+
+
+ Legal notice
+
+ Copyright © 2026 Albrecht Degering. Regex Tools is free software:
+ you may redistribute it and/or modify it under the terms of the GNU
+ General Public License, version 3 or (at your option) any later
+ version. It comes without any warranty, to the extent permitted by
+ law.
+
+
+ Read the{" "}
+
+ complete licence
+ {" "}
+ or inspect and obtain the{" "}
+
+ corresponding source code
+
+ .
+
+
+
+
+
+ );
+}
diff --git a/src/components/ListPanel.tsx b/src/components/ListPanel.tsx
new file mode 100644
index 0000000..7309c30
--- /dev/null
+++ b/src/components/ListPanel.tsx
@@ -0,0 +1,226 @@
+import { useMemo, useState } from "react";
+import {
+ exportListRows,
+ parseListTemplate,
+ renderListRows,
+ type ListRow,
+} from "../regex/list/list-template";
+import type { RegexMatchResult } from "../regex/model/match";
+import {
+ DEFAULT_REGEX_LIMITS,
+ TEMPLATE_PRESENTATION_LIMITS,
+} from "../regex/execution/request-limits";
+
+function download(text: string, format: string): void {
+ const mediaTypes: Record = {
+ text: "text/plain;charset=utf-8",
+ csv: "text/csv;charset=utf-8",
+ json: "application/json",
+ ndjson: "application/x-ndjson",
+ };
+ const extensions: Record = {
+ text: "txt",
+ csv: "csv",
+ json: "json",
+ ndjson: "ndjson",
+ };
+ const url = URL.createObjectURL(
+ new Blob([text], { type: mediaTypes[format] }),
+ );
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `regex-extraction.${extensions[format]}`;
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+}
+
+function ListPreview({ rows }: { readonly rows: readonly ListRow[] }) {
+ return rows.length > 0 ? (
+
+ {rows
+ .slice(0, TEMPLATE_PRESENTATION_LIMITS.listPreviewRows)
+ .map((row) => (
+
+ {row.value || "∅"}
+
+ ))}
+
+ ) : (
+ No rows generated.
+ );
+}
+
+export function ListPanel({
+ templateSource,
+ onTemplateChange,
+ matches,
+ subject,
+ sourceResultTruncated = false,
+}: {
+ readonly templateSource: string;
+ readonly onTemplateChange: (value: string) => void;
+ readonly matches: readonly RegexMatchResult[];
+ readonly subject: string;
+ readonly sourceResultTruncated?: boolean;
+}) {
+ const [format, setFormat] = useState<"text" | "csv" | "json" | "ndjson">(
+ "text",
+ );
+ const template = useMemo(
+ () => parseListTemplate(templateSource),
+ [templateSource],
+ );
+ const rendered = useMemo(
+ () =>
+ renderListRows(
+ template,
+ matches,
+ subject,
+ "test-text",
+ sourceResultTruncated,
+ ),
+ [matches, sourceResultTruncated, subject, template],
+ );
+ const rows = rendered.rows;
+ const incompleteRows = rows.filter((row) => !row.complete);
+ const renderedTokenChips = template.tokens.slice(
+ 0,
+ TEMPLATE_PRESENTATION_LIMITS.listTokenChips,
+ );
+ return (
+
+
+
+
+ Template
+
+
+ Tokens: $0, $1…$1000,{" "}
+ ${"{name}"}, {"{{match}}"},{" "}
+ {"{{line}}"}, and {"{{source}}"}.
+
+
+ {templateSource.length.toLocaleString()} /{" "}
+ {DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16.toLocaleString()}{" "}
+ UTF-16 units
+
+ {!rendered.templateAccepted ? (
+
+ List template rejected. {rendered.warnings[0]}
+ Exact rendering and export are disabled.
+
+ ) : null}
+
+ {renderedTokenChips.map((token, index) => (
+ {token.kind}
+ ))}
+
+ {template.tokens.length >
+ TEMPLATE_PRESENTATION_LIMITS.listTokenChips ? (
+
+ Token chips limited. Showing the first{" "}
+ {TEMPLATE_PRESENTATION_LIMITS.listTokenChips.toLocaleString()} of{" "}
+ {template.tokens.length.toLocaleString()} complete template tokens.
+
+ ) : null}
+
+
+
+
+
Bounded preview
+
Generated rows
+
+
+ {rendered.templateAccepted
+ ? rendered.sourceResultTruncated
+ ? `${rows.length.toLocaleString()} returned · engine limit reached`
+ : `${rows.length}${
+ rendered.truncated ? ` of ${matches.length}` : ""
+ }`
+ : "Template unavailable"}
+
+
+
+ {rows.length > TEMPLATE_PRESENTATION_LIMITS.listPreviewRows ? (
+
+ Row preview limited. Showing the first{" "}
+ {TEMPLATE_PRESENTATION_LIMITS.listPreviewRows.toLocaleString()} of{" "}
+ {rows.length.toLocaleString()} generated rows. Eligible exports
+ still contain every generated row.
+
+ ) : null}
+ {incompleteRows.length > 0 ||
+ rendered.truncated ||
+ !rendered.templateAccepted ? (
+
+ {!rendered.templateAccepted || rendered.truncated
+ ? rendered.warnings[0]
+ : `Exact values are unavailable for ${incompleteRows.length} row${
+ incompleteRows.length === 1 ? "" : "s"
+ }.`}{" "}
+ Export is disabled so bounded previews cannot be mistaken for
+ complete data.
+
+ ) : null}
+
+
+
+
+
+ Format
+
+ setFormat(
+ event.target.value as "text" | "csv" | "json" | "ndjson",
+ )
+ }
+ >
+ Plain text
+ CSV
+ JSON
+ NDJSON
+
+
+ 0 ||
+ rendered.truncated ||
+ !rendered.templateAccepted
+ }
+ onClick={() => download(exportListRows(rows, format), format)}
+ >
+ Download {format.toUpperCase()}
+
+
+
+
+ );
+}
diff --git a/src/components/ProjectPanel.tsx b/src/components/ProjectPanel.tsx
new file mode 100644
index 0000000..ac4a4d9
--- /dev/null
+++ b/src/components/ProjectPanel.tsx
@@ -0,0 +1,192 @@
+import { useRef, useState } from "react";
+import type { RegexProjectV1 } from "../project/project.types";
+import {
+ importProjectDocument,
+ projectFileName,
+ serializeProject,
+} from "../project/project.serialization";
+import { MAXIMUM_PROJECT_DOCUMENT_BYTES } from "../project/project-limits";
+
+function downloadProject(project: RegexProjectV1, serialized: string): void {
+ const url = URL.createObjectURL(
+ new Blob([serialized], { type: "application/json" }),
+ );
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = projectFileName(project);
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+}
+
+export function ProjectPanel({
+ project,
+ open,
+ onClose,
+ onImport,
+ onSaveLocal,
+ onLoadLocal,
+}: {
+ readonly project: RegexProjectV1;
+ readonly open: boolean;
+ readonly onClose: () => void;
+ readonly onImport: (project: RegexProjectV1) => void;
+ readonly onSaveLocal: (
+ includeText: boolean,
+ includeUnitTestSubjects: boolean,
+ ) => Promise;
+ readonly onLoadLocal: () => Promise;
+}) {
+ const input = useRef(null);
+ const [includeText, setIncludeText] = useState(false);
+ const [includeTestSubjects, setIncludeTestSubjects] = useState(false);
+ const [includeLocalTestSubjects, setIncludeLocalTestSubjects] =
+ useState(false);
+ const [status, setStatus] = useState("");
+ if (!open) return null;
+ return (
+
+
+
+
Versioned local project
+
Project
+
+
+ ×
+
+
+
+ {project.name} · schema {project.schemaVersion}
+
+
+ setIncludeText(event.target.checked)}
+ />
+ Include interactive test text
+
+
+ setIncludeTestSubjects(event.target.checked)}
+ />
+ Include unit-test subjects in export
+
+
+
+ setIncludeLocalTestSubjects(event.target.checked)
+ }
+ />
+ Include unit-test subjects in local save
+
+ {includeText || includeTestSubjects || includeLocalTestSubjects ? (
+
+ Included subject text may contain sensitive data. The file remains
+ local unless you share it.
+
+ ) : null}
+
+ {
+ try {
+ const serialized = serializeProject(project, {
+ includeTestText: includeText,
+ includeUnitTestSubjects: includeTestSubjects,
+ });
+ downloadProject(project, serialized);
+ setStatus("Exported project JSON.");
+ } catch (error: unknown) {
+ setStatus(error instanceof Error ? error.message : String(error));
+ }
+ }}
+ >
+ Export JSON
+
+ input.current?.click()}
+ >
+ Import JSON
+
+ {
+ void onSaveLocal(includeText, includeLocalTestSubjects)
+ .then(() => setStatus("Saved locally."))
+ .catch((error: unknown) =>
+ setStatus(
+ error instanceof Error ? error.message : String(error),
+ ),
+ );
+ }}
+ >
+ Save locally
+
+ {
+ void onLoadLocal()
+ .then(() =>
+ setStatus("Loaded locally; live execution is paused."),
+ )
+ .catch((error: unknown) =>
+ setStatus(
+ error instanceof Error ? error.message : String(error),
+ ),
+ );
+ }}
+ >
+ Load local save
+
+
+ {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ if (file.size > MAXIMUM_PROJECT_DOCUMENT_BYTES) {
+ setStatus("Project document exceeds the 32 MiB import limit.");
+ event.target.value = "";
+ return;
+ }
+ void file
+ .text()
+ .then(importProjectDocument)
+ .then((imported) => {
+ onImport(imported);
+ setStatus("Imported and validated; live execution is paused.");
+ })
+ .catch((error: unknown) =>
+ setStatus(error instanceof Error ? error.message : String(error)),
+ );
+ event.target.value = "";
+ }}
+ />
+
+ {status}
+
+
+ );
+}
diff --git a/src/components/QuickReference.test.tsx b/src/components/QuickReference.test.tsx
new file mode 100644
index 0000000..1afb5a5
--- /dev/null
+++ b/src/components/QuickReference.test.tsx
@@ -0,0 +1,26 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { QuickReference } from "./QuickReference";
+
+describe("QuickReference", () => {
+ it("shows flavour, flags, example and authoritative source before insertion", async () => {
+ const onInsert = vi.fn();
+ const user = userEvent.setup();
+ render( );
+
+ await user.type(
+ screen.getByLabelText("Filter constructs"),
+ "Any character",
+ );
+ expect(screen.getByText("ecmascript")).toBeInTheDocument();
+ expect(screen.getByText("s", { selector: "dd" })).toBeInTheDocument();
+ expect(screen.getByText("a.b")).toBeInTheDocument();
+ expect(
+ screen.getByRole("link", { name: "ECMAScript Atom grammar" }),
+ ).toHaveAttribute("href", expect.stringContaining("tc39.es"));
+
+ await user.click(screen.getByRole("button", { name: "Insert example" }));
+ expect(onInsert).toHaveBeenCalledWith("a.b");
+ });
+});
diff --git a/src/components/QuickReference.tsx b/src/components/QuickReference.tsx
new file mode 100644
index 0000000..e28fdac
--- /dev/null
+++ b/src/components/QuickReference.tsx
@@ -0,0 +1,94 @@
+import { useMemo, useState } from "react";
+import { ECMASCRIPT_REFERENCE } from "../regex/reference/token-reference";
+
+export function QuickReference({
+ onInsert,
+}: {
+ readonly onInsert: (syntax: string) => void;
+}) {
+ const [query, setQuery] = useState("");
+ const entries = useMemo(() => {
+ const needle = query.toLocaleLowerCase();
+ return needle
+ ? ECMASCRIPT_REFERENCE.filter((entry) =>
+ [
+ entry.construct,
+ entry.syntax,
+ entry.explanation,
+ entry.example,
+ entry.flags.join(" "),
+ entry.source,
+ ]
+ .join(" ")
+ .toLocaleLowerCase()
+ .includes(needle),
+ )
+ : ECMASCRIPT_REFERENCE;
+ }, [query]);
+ return (
+
+ );
+}
diff --git a/src/components/ReplacementPanel.completeness.test.tsx b/src/components/ReplacementPanel.completeness.test.tsx
new file mode 100644
index 0000000..28536f0
--- /dev/null
+++ b/src/components/ReplacementPanel.completeness.test.tsx
@@ -0,0 +1,89 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it } from "vitest";
+import type {
+ RegexExecutionResult,
+ RegexReplacementResult,
+} from "../regex/model/match";
+import { ReplacementPanel } from "./ReplacementPanel";
+
+afterEach(cleanup);
+
+const execution: RegexExecutionResult = {
+ accepted: true,
+ engine: {
+ flavour: "ecmascript",
+ adapterVersion: "test",
+ engineName: "Test ECMAScript engine",
+ engineVersion: "test",
+ offsetUnit: "utf16",
+ capabilities: {
+ compilation: true,
+ matching: true,
+ replacement: true,
+ namedCaptures: true,
+ captureHistory: false,
+ actualTrace: false,
+ benchmark: false,
+ },
+ },
+ flags: {
+ userFlags: "g",
+ effectiveFlags: "dg",
+ internallyAddedIndicesFlag: true,
+ internallyAddedGlobalFlag: false,
+ },
+ matches: [],
+ diagnostics: [],
+ elapsedMs: 1,
+ truncated: false,
+};
+
+function renderResult(result: RegexReplacementResult) {
+ return render(
+ undefined}
+ result={result}
+ onSelectToken={() => undefined}
+ onSelectContribution={() => undefined}
+ />,
+ );
+}
+
+describe("ReplacementPanel completeness", () => {
+ it("labels a match-limited replacement as partial", () => {
+ renderResult({
+ execution: { ...execution, truncated: true },
+ output: "xa",
+ outputBytes: 2,
+ outputTruncated: false,
+ truncated: true,
+ });
+
+ expect(
+ screen.getByRole("heading", { name: "Replacement output" }),
+ ).toBeInTheDocument();
+ expect(screen.queryByText("Complete output")).not.toBeInTheDocument();
+ expect(
+ screen.getByText(/partial · match\/capture limit reached/u),
+ ).toBeInTheDocument();
+ expect(screen.getByTestId("replacement-match-limit")).toHaveTextContent(
+ "later subject text remains unchanged",
+ );
+ });
+
+ it("distinguishes byte-truncated output from match incompleteness", () => {
+ renderResult({
+ execution,
+ output: "prefix",
+ outputBytes: 6,
+ outputTruncated: true,
+ truncated: true,
+ });
+
+ expect(screen.getByText(/output preview truncated/u)).toBeInTheDocument();
+ expect(
+ screen.queryByTestId("replacement-match-limit"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/src/components/ReplacementPanel.tsx b/src/components/ReplacementPanel.tsx
new file mode 100644
index 0000000..5ccc7f2
--- /dev/null
+++ b/src/components/ReplacementPanel.tsx
@@ -0,0 +1,284 @@
+import { CodeEditor } from "../editors/CodeEditor";
+import type {
+ ReplacementSyntaxResult,
+ ReplacementToken,
+ SourceRange,
+} from "../regex/model/syntax";
+import type { RegexReplacementResult } from "../regex/model/match";
+import type {
+ ReplacementContributionPreview,
+ ReplacementPreview,
+} from "../regex/replacement/replacement-preview";
+import {
+ DEFAULT_REGEX_LIMITS,
+ TEMPLATE_PRESENTATION_LIMITS,
+} from "../regex/execution/request-limits";
+import { handleTreeNavigation } from "./tree-navigation";
+import { ReplacementPreviewPanel } from "./ReplacementPreviewPanel";
+
+function ReplacementTokenRow({
+ token,
+ selected,
+ focusable,
+ onSelect,
+}: {
+ readonly token: ReplacementToken;
+ readonly selected: boolean;
+ readonly focusable: boolean;
+ readonly onSelect: (token: ReplacementToken) => void;
+}) {
+ const rawPreview =
+ token.raw.length <= 160 ? token.raw : `${token.raw.slice(0, 160)}…`;
+ return (
+
+ onSelect(token)}
+ onKeyDown={handleTreeNavigation}
+ >
+ {rawPreview || "∅"}
+ {token.explanation}
+ {token.kind.replaceAll("-", " ")}
+
+
+ );
+}
+
+export function ReplacementPanel({
+ replacement,
+ onReplacementChange,
+ syntax,
+ result,
+ preview,
+ selectedToken,
+ selectedContributionId,
+ selectedOutputRange,
+ onSelectToken,
+ onSelectContribution,
+}: {
+ readonly replacement: string;
+ readonly onReplacementChange: (value: string) => void;
+ readonly syntax?: ReplacementSyntaxResult;
+ readonly result?: RegexReplacementResult;
+ readonly preview?: ReplacementPreview;
+ readonly selectedToken?: ReplacementToken;
+ readonly selectedContributionId?: string;
+ readonly selectedOutputRange?: SourceRange;
+ readonly onSelectToken: (token: ReplacementToken) => void;
+ readonly onSelectContribution: (
+ contribution: ReplacementContributionPreview,
+ ) => void;
+}) {
+ const retainedTokens = syntax?.tokens ?? [];
+ const editorTokens = retainedTokens.slice(
+ 0,
+ TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
+ );
+ const renderedTokens = retainedTokens.slice(
+ 0,
+ TEMPLATE_PRESENTATION_LIMITS.replacementTokenRows,
+ );
+ const selectedTokenRendered = renderedTokens.some(
+ (token) => token.id === selectedToken?.id,
+ );
+ const inputLimited =
+ replacement.length > DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16;
+
+ return (
+
+
+
+ ({
+ id: token.id,
+ range: token.range,
+ className: `cm-replacement-token cm-replacement-${token.kind}`,
+ label: token.explanation,
+ })),
+ ...(selectedToken
+ ? [
+ {
+ id: "selected-replacement-token",
+ range: selectedToken.range,
+ className: "cm-selected-range",
+ label: "Selected replacement token",
+ },
+ ]
+ : []),
+ ]}
+ placeholder="Use $&, $1, or $"
+ />
+ {inputLimited ? (
+
+ Replacement template rejected. This input has{" "}
+ {replacement.length.toLocaleString()} UTF-16 units; parsing and
+ execution stop above the{" "}
+ {DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()}{" "}
+ unit limit.
+
+ ) : null}
+ {retainedTokens.length >
+ TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks ? (
+
+ Editor decorations limited. Showing the first{" "}
+ {TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks.toLocaleString()}{" "}
+ of {retainedTokens.length.toLocaleString()} complete replacement
+ tokens.
+
+ ) : null}
+
+
+
+ {renderedTokens.length > 0 ? (
+ <>
+ {retainedTokens.length >
+ TEMPLATE_PRESENTATION_LIMITS.replacementTokenRows ? (
+
+ Token list limited. Showing the first{" "}
+ {TEMPLATE_PRESENTATION_LIMITS.replacementTokenRows.toLocaleString()}{" "}
+ of {retainedTokens.length.toLocaleString()} complete tokens. The
+ full template remains the execution input.
+
+ ) : null}
+
+ {renderedTokens.map((token) => (
+
+ ))}
+
+ >
+ ) : (
+
+ {syntax?.accepted === false
+ ? "Token parsing is unavailable until the template is within the input limit."
+ : "Enter a replacement template."}
+
+ )}
+ {syntax?.diagnosticsTruncated ? (
+
+ Diagnostics limited. The diagnostics panel retains
+ at most{" "}
+ {TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics.toLocaleString()}{" "}
+ entries, including a summary, for{" "}
+ {(
+ syntax.totalDiagnostics ?? syntax.diagnostics.length
+ ).toLocaleString()}{" "}
+ findings.
+
+ ) : null}
+
+
+
+
+ {result?.execution.truncated ? (
+
+ Replacement application is partial. Only the
+ authoritative matches returned before the match or capture-row limit
+ were replaced; later subject text remains unchanged.
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/components/ReplacementPreviewPanel.test.tsx b/src/components/ReplacementPreviewPanel.test.tsx
new file mode 100644
index 0000000..9b57db0
--- /dev/null
+++ b/src/components/ReplacementPreviewPanel.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import type {
+ ReplacementContributionPreview,
+ ReplacementPreview,
+} from "../regex/replacement/replacement-preview";
+import { ReplacementPreviewPanel } from "./ReplacementPreviewPanel";
+
+const contribution: ReplacementContributionPreview = {
+ id: "replacement-contribution-1-token-1",
+ matchNumber: 1,
+ token: {
+ id: "token-1",
+ kind: "numbered-capture",
+ raw: "$1",
+ range: { startUtf16: 1, endUtf16: 3 },
+ explanation: "Insert numbered capture 1",
+ captureNumber: 1,
+ },
+ valuePreview: "a",
+ valueLengthUtf16: 1,
+ valueLengthExact: true,
+ valueTruncated: false,
+ sourceRange: { startUtf16: 0, endUtf16: 1 },
+ outputRange: { startUtf16: 1, endUtf16: 2 },
+};
+
+const preview: ReplacementPreview = {
+ matches: [
+ {
+ id: "replacement-match-1",
+ matchNumber: 1,
+ originalRange: { startUtf16: 0, endUtf16: 1 },
+ originalPreview: "a",
+ originalLengthUtf16: 1,
+ originalTruncated: false,
+ replacementPreview: "[a]",
+ replacementLengthUtf16: 3,
+ replacementLengthExact: true,
+ replacementTruncated: false,
+ outputRange: { startUtf16: 0, endUtf16: 3 },
+ contributions: [contribution],
+ },
+ ],
+ totalMatches: 1,
+ totalContributions: 1,
+ renderedContributions: 1,
+ truncated: false,
+};
+
+describe("ReplacementPreviewPanel", () => {
+ it("shows original, replacement and changed ranges and selects a contribution", async () => {
+ const onSelect = vi.fn();
+ render(
+ ,
+ );
+
+ expect(screen.getByText("Original match")).toBeInTheDocument();
+ expect(screen.getByText("Replacement result")).toBeInTheDocument();
+ expect(screen.getByText(/source 0…1 → output 0…3/u)).toBeInTheDocument();
+ await userEvent.click(
+ screen.getByRole("button", {
+ name: /Insert numbered capture 1/u,
+ }),
+ );
+ expect(onSelect).toHaveBeenCalledWith(contribution);
+ });
+});
diff --git a/src/components/ReplacementPreviewPanel.tsx b/src/components/ReplacementPreviewPanel.tsx
new file mode 100644
index 0000000..0f65eb6
--- /dev/null
+++ b/src/components/ReplacementPreviewPanel.tsx
@@ -0,0 +1,166 @@
+import type {
+ ReplacementContributionPreview,
+ ReplacementPreview,
+} from "../regex/replacement/replacement-preview";
+
+function rangeLabel(
+ range:
+ | {
+ readonly startUtf16: number;
+ readonly endUtf16: number;
+ }
+ | undefined,
+ unavailableLabel = "unavailable",
+): string {
+ return range
+ ? `${range.startUtf16.toLocaleString()}…${range.endUtf16.toLocaleString()}`
+ : unavailableLabel;
+}
+
+function previewLabel(
+ value: string,
+ lengthUtf16: number,
+ truncated: boolean,
+): string {
+ if (value === "" && lengthUtf16 === 0) return "∅ empty";
+ return `${value}${truncated ? " …" : ""}`;
+}
+
+export function ReplacementPreviewPanel({
+ preview,
+ selectedContributionId,
+ onSelectContribution,
+}: {
+ readonly preview?: ReplacementPreview;
+ readonly selectedContributionId?: string;
+ readonly onSelectContribution: (
+ contribution: ReplacementContributionPreview,
+ ) => void;
+}) {
+ return (
+
+
+ {preview?.truncated ? (
+
+ Replacement preview limited. Showing{" "}
+ {preview.matches.length.toLocaleString()} of{" "}
+ {preview.totalMatches.toLocaleString()} matches and{" "}
+ {preview.renderedContributions.toLocaleString()} of{" "}
+ {preview.totalContributions.toLocaleString()} token contributions. The
+ complete bounded output remains in the output editor.
+
+ ) : null}
+ {(preview?.matches.length ?? 0) > 0 ? (
+
+ {preview?.matches.map((match) => (
+
+
+
+ Match {match.matchNumber.toLocaleString()}
+
+ source {rangeLabel(match.originalRange)} → output{" "}
+ {rangeLabel(
+ match.outputRange,
+ "not available in bounded output",
+ )}
+
+
+
+
+ Original match
+
+ {previewLabel(
+ match.originalPreview,
+ match.originalLengthUtf16,
+ match.originalTruncated,
+ )}
+
+
+
+ Replacement result
+
+ {previewLabel(
+ match.replacementPreview,
+ match.replacementLengthUtf16,
+ match.replacementTruncated,
+ )}
+
+
+
+ {match.contributions.length > 0 ? (
+
+ {match.contributions.map((contribution) => (
+
+ onSelectContribution(contribution)}
+ >
+ {contribution.token.raw || "∅"}
+
+ {previewLabel(
+ contribution.valuePreview,
+ contribution.valueLengthUtf16,
+ contribution.valueTruncated,
+ )}
+
+
+ {contribution.token.explanation} · source{" "}
+ {rangeLabel(
+ contribution.sourceRange,
+ "literal or non-participating",
+ )}{" "}
+ · output{" "}
+ {rangeLabel(
+ contribution.outputRange,
+ "not available in bounded output",
+ )}
+
+
+
+ ))}
+
+ ) : (
+
+ This empty template removes the match.
+
+ )}
+
+
+ ))}
+
+ ) : (
+
+ Run a valid pattern in Replace mode to inspect each substitution.
+
+ )}
+
+ );
+}
diff --git a/src/components/TemplateLimits.test.tsx b/src/components/TemplateLimits.test.tsx
new file mode 100644
index 0000000..3a59987
--- /dev/null
+++ b/src/components/TemplateLimits.test.tsx
@@ -0,0 +1,172 @@
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { ListPanel } from "./ListPanel";
+import { ReplacementPanel } from "./ReplacementPanel";
+import {
+ DEFAULT_REGEX_LIMITS,
+ TEMPLATE_PRESENTATION_LIMITS,
+} from "../regex/execution/request-limits";
+import type { EditorMark } from "../editors/editor.types";
+import type { ReplacementSyntaxResult } from "../regex/model/syntax";
+import type { RegexMatchResult } from "../regex/model/match";
+
+const editorHarness = vi.hoisted(() => ({
+ marks: [] as readonly EditorMark[],
+}));
+
+vi.mock("../editors/CodeEditor", () => ({
+ CodeEditor({
+ value,
+ label,
+ marks = [],
+ }: {
+ readonly value: string;
+ readonly label: string;
+ readonly marks?: readonly EditorMark[];
+ }) {
+ if (label === "Replacement template") editorHarness.marks = marks;
+ return ;
+ },
+}));
+
+afterEach(() => {
+ cleanup();
+ editorHarness.marks = [];
+});
+
+describe("template presentation limits", () => {
+ it("bounds replacement marks, rows and diagnostics while reporting exact totals", () => {
+ const tokenCount = TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks + 10;
+ const syntax: ReplacementSyntaxResult = {
+ accepted: true,
+ tokens: Array.from({ length: tokenCount }, (_, index) => ({
+ id: `replacement-${index}`,
+ kind: "full-match" as const,
+ raw: "$&",
+ range: { startUtf16: index * 2, endUtf16: index * 2 + 2 },
+ explanation: "Insert the complete match",
+ })),
+ diagnostics: [],
+ totalDiagnostics: 1_000,
+ diagnosticsTruncated: true,
+ };
+
+ const { container } = render(
+ undefined}
+ syntax={syntax}
+ onSelectToken={() => undefined}
+ onSelectContribution={() => undefined}
+ />,
+ );
+
+ expect(editorHarness.marks).toHaveLength(
+ TEMPLATE_PRESENTATION_LIMITS.replacementEditorMarks,
+ );
+ expect(container.querySelectorAll(".token-list li")).toHaveLength(
+ TEMPLATE_PRESENTATION_LIMITS.replacementTokenRows,
+ );
+ expect(
+ screen.getByTestId("replacement-mark-render-limit"),
+ ).toHaveTextContent(tokenCount.toLocaleString());
+ expect(
+ screen.getByTestId("replacement-token-render-limit"),
+ ).toHaveTextContent(tokenCount.toLocaleString());
+ expect(
+ screen.getByTestId("replacement-diagnostic-render-limit"),
+ ).toHaveTextContent("1,000");
+ });
+
+ it("shows an explicit replacement input-limit error", () => {
+ render(
+ undefined}
+ syntax={{
+ accepted: false,
+ tokens: [],
+ diagnostics: [],
+ totalDiagnostics: 1,
+ diagnosticsTruncated: false,
+ }}
+ onSelectToken={() => undefined}
+ onSelectContribution={() => undefined}
+ />,
+ );
+
+ expect(screen.getByTestId("replacement-input-limit")).toHaveTextContent(
+ "parsing and execution stop",
+ );
+ });
+
+ it("bounds list token chips and refuses oversized-template export", () => {
+ const { container, rerender } = render(
+ undefined}
+ matches={[]}
+ subject=""
+ />,
+ );
+
+ expect(container.querySelectorAll(".token-chips span")).toHaveLength(
+ TEMPLATE_PRESENTATION_LIMITS.listTokenChips,
+ );
+ expect(screen.getByTestId("list-token-render-limit")).toHaveTextContent(
+ (TEMPLATE_PRESENTATION_LIMITS.listTokenChips + 10).toLocaleString(),
+ );
+
+ rerender(
+ undefined}
+ matches={[]}
+ subject=""
+ />,
+ );
+
+ expect(screen.getByTestId("list-template-input-limit")).toHaveTextContent(
+ "Exact rendering and export are disabled",
+ );
+ expect(
+ screen.getByRole("button", { name: "Download TEXT" }),
+ ).toBeDisabled();
+ });
+
+ it("disables list export when the engine returned only a match prefix", () => {
+ const matches: readonly RegexMatchResult[] = [
+ {
+ matchNumber: 1,
+ value: "a",
+ valueStatus: "complete",
+ range: { startUtf16: 0, endUtf16: 1 },
+ nativeRange: { start: 0, end: 1, unit: "utf16" },
+ captures: [],
+ },
+ ];
+ render(
+ undefined}
+ matches={matches}
+ subject="a"
+ sourceResultTruncated
+ />,
+ );
+
+ expect(screen.getByText(/engine limit reached/u)).toBeInTheDocument();
+ expect(
+ screen.getByText(/engine stopped collecting matches/u),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Download TEXT" }),
+ ).toBeDisabled();
+ });
+});
diff --git a/src/components/UnitTestPanel.test.tsx b/src/components/UnitTestPanel.test.tsx
new file mode 100644
index 0000000..989f230
--- /dev/null
+++ b/src/components/UnitTestPanel.test.tsx
@@ -0,0 +1,573 @@
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type {
+ RegexTestCase,
+ RegexTestResult,
+} from "../regex/tests/test-case.types";
+import {
+ UnitTestPanel,
+ type RegexTestDraft,
+ type UnitTestPanelProps,
+} from "./UnitTestPanel";
+
+afterEach(cleanup);
+
+function renderPanel(
+ overrides: Partial = {},
+): UnitTestPanelProps {
+ const props: UnitTestPanelProps = {
+ tests: [],
+ results: new Map(),
+ currentPattern: "current-pattern",
+ currentFlags: ["u"],
+ currentScanAll: false,
+ currentSubject: "current subject",
+ currentReplacement: "current replacement",
+ onAdd: vi.fn(),
+ onUpdate: vi.fn(),
+ onClone: vi.fn(),
+ onImport: vi.fn(),
+ onRemove: vi.fn(),
+ onToggle: vi.fn(),
+ onRunAll: vi.fn(),
+ onRunSelected: vi.fn(),
+ onCancel: vi.fn(),
+ running: false,
+ ...overrides,
+ };
+ render( );
+ return props;
+}
+
+async function replaceInput(
+ user: ReturnType,
+ element: HTMLElement,
+ value: string,
+): Promise {
+ await user.clear(element);
+ await user.type(element, value);
+}
+
+function submittedDrafts(props: UnitTestPanelProps): readonly RegexTestDraft[] {
+ return vi.mocked(props.onAdd).mock.calls.map(([draft]) => draft);
+}
+
+describe("UnitTestPanel", () => {
+ it("creates both presence assertions from the current subject", async () => {
+ const user = userEvent.setup();
+ const props = renderPanel();
+
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expectation" }),
+ "should-not-match",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(submittedDrafts(props)).toEqual([
+ {
+ name: "Current example",
+ pattern: "current-pattern",
+ flags: ["u"],
+ scanAll: false,
+ subject: "current subject",
+ expectation: { kind: "should-match" },
+ },
+ {
+ name: "Current example",
+ pattern: "current-pattern",
+ flags: ["u"],
+ scanAll: false,
+ subject: "current subject",
+ expectation: { kind: "should-not-match" },
+ },
+ ]);
+ });
+
+ it("creates count, full-match, completion, and timeout assertions", async () => {
+ const user = userEvent.setup();
+ const props = renderPanel();
+ const expectation = screen.getByRole("combobox", { name: "Expectation" });
+
+ await user.selectOptions(expectation, "match-count");
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", { name: "Expected match count" }),
+ "3",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ await user.selectOptions(expectation, "full-match");
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", {
+ name: "Match index (zero-based)",
+ }),
+ "2",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Expected full match" }),
+ "three",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ await user.selectOptions(expectation, "must-complete-within");
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", { name: "Completion limit (ms)" }),
+ "75",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ await user.selectOptions(expectation, "must-time-out");
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(submittedDrafts(props).map((draft) => draft.expectation)).toEqual([
+ { kind: "match-count", count: 3 },
+ { kind: "full-match", matchIndex: 2, value: "three" },
+ { kind: "must-complete-within", milliseconds: 75 },
+ { kind: "must-time-out" },
+ ]);
+ });
+
+ it("creates capture assertions by number and by name with optional value", async () => {
+ const user = userEvent.setup();
+ const props = renderPanel();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expectation" }),
+ "capture",
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", { name: "Capture number" }),
+ "4",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expected capture status" }),
+ "matched-empty",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Identify capture by" }),
+ "name",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Capture name" }),
+ "word",
+ );
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expected capture status" }),
+ "participated",
+ );
+ await user.click(
+ screen.getByRole("checkbox", {
+ name: "Also assert the exact capture value",
+ }),
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Expected capture value" }),
+ "hello",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(submittedDrafts(props).map((draft) => draft.expectation)).toEqual([
+ {
+ kind: "capture",
+ matchIndex: 0,
+ groupNumber: 4,
+ status: "matched-empty",
+ },
+ {
+ kind: "capture",
+ matchIndex: 0,
+ groupName: "word",
+ status: "participated",
+ value: "hello",
+ },
+ ]);
+ });
+
+ it("creates replacement assertions with optional template and custom timeout", async () => {
+ const user = userEvent.setup();
+ const props = renderPanel();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expectation" }),
+ "replacement",
+ );
+ await user.type(
+ screen.getByRole("textbox", { name: "Expected replacement output" }),
+ "Hello Ada",
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", {
+ name: "Per-test timeout (ms, optional)",
+ }),
+ "750",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ await user.click(
+ screen.getByRole("checkbox", {
+ name: "Store a replacement template for this test",
+ }),
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("textbox", { name: "Replacement template" }),
+ "Hello $",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(submittedDrafts(props)).toEqual([
+ {
+ name: "Current example",
+ pattern: "current-pattern",
+ flags: ["u"],
+ scanAll: false,
+ subject: "current subject",
+ expectation: {
+ kind: "replacement",
+ expected: "Hello Ada",
+ },
+ resourceLimits: {
+ manualExecutionTimeoutMs: 750,
+ },
+ },
+ {
+ name: "Current example",
+ pattern: "current-pattern",
+ flags: ["u"],
+ scanAll: false,
+ subject: "current subject",
+ expectation: {
+ kind: "replacement",
+ expected: "Hello Ada",
+ },
+ replacement: "Hello $",
+ resourceLimits: {
+ manualExecutionTimeoutMs: 750,
+ },
+ },
+ ]);
+ });
+
+ it("reports invalid assertion and timeout bounds without adding a test", async () => {
+ const user = userEvent.setup();
+ const props = renderPanel();
+
+ await user.selectOptions(
+ screen.getByRole("combobox", { name: "Expectation" }),
+ "match-count",
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", { name: "Expected match count" }),
+ "1000001",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Expected match count must be a whole number",
+ );
+ expect(props.onAdd).not.toHaveBeenCalled();
+
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", { name: "Expected match count" }),
+ "1",
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("spinbutton", {
+ name: "Per-test timeout (ms, optional)",
+ }),
+ "10001",
+ );
+ await user.click(screen.getByRole("button", { name: "Add test" }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "Per-test timeout must be a whole number",
+ );
+ expect(props.onAdd).not.toHaveBeenCalled();
+ });
+
+ it("rejects an astral subject above the execution UTF-8 byte cap", () => {
+ const props = renderPanel();
+ const astralSubject = "😀".repeat(4 * 1024 * 1024 + 1);
+
+ fireEvent.change(screen.getByRole("textbox", { name: "Subject" }), {
+ target: { value: astralSubject },
+ });
+ fireEvent.submit(
+ screen.getByRole("textbox", { name: "Name" }).closest("form")!,
+ );
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "16,777,216 UTF-8 byte limit",
+ );
+ expect(props.onAdd).not.toHaveBeenCalled();
+ });
+
+ it("preserves suite run, toggle, remove, and result behavior", async () => {
+ const user = userEvent.setup();
+ const test: RegexTestCase = {
+ id: "test-1",
+ name: "Greeting",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "hello",
+ flags: ["i"],
+ options: {},
+ subject: "Hello",
+ expectation: { kind: "match-count", count: 1 },
+ };
+ const result: RegexTestResult = {
+ testId: test.id,
+ passed: true,
+ elapsedMs: 1,
+ message: "Matched once.",
+ timedOut: false,
+ };
+ const props = renderPanel({
+ tests: [test],
+ results: new Map([[test.id, result]]),
+ });
+
+ expect(screen.getByText("match count: 1", { exact: false })).toBeVisible();
+ expect(screen.getByRole("status")).toHaveTextContent(
+ "Pass — Matched once.",
+ );
+
+ await user.click(screen.getByRole("checkbox", { name: "Enable Greeting" }));
+ await user.click(screen.getByRole("button", { name: "Remove Greeting" }));
+ await user.click(screen.getByRole("button", { name: "Run all" }));
+
+ expect(props.onToggle).toHaveBeenCalledWith("test-1");
+ expect(props.onRemove).toHaveBeenCalledWith("test-1");
+ expect(props.onRunAll).toHaveBeenCalledOnce();
+ });
+
+ it("locks suite mutations but keeps cancellation available while running", async () => {
+ const user = userEvent.setup();
+ const test: RegexTestCase = {
+ id: "test-1",
+ name: "Snapshot case",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "snapshot",
+ flags: [],
+ options: {},
+ subject: "snapshot",
+ expectation: { kind: "should-match" },
+ };
+ const props = renderPanel({
+ tests: [test],
+ running: true,
+ });
+
+ expect(screen.getByRole("button", { name: "Add test" })).toBeDisabled();
+ expect(
+ screen.getByRole("checkbox", { name: "Enable Snapshot case" }),
+ ).toBeDisabled();
+ expect(
+ screen.getByRole("button", { name: "Remove Snapshot case" }),
+ ).toBeDisabled();
+ expect(
+ screen.queryByRole("button", { name: "Run all" }),
+ ).not.toBeInTheDocument();
+
+ const cancel = screen.getByRole("button", { name: "Cancel" });
+ expect(cancel).toBeEnabled();
+ fireEvent.submit(
+ screen.getByRole("textbox", { name: "Name" }).closest("form")!,
+ );
+ await user.click(cancel);
+
+ expect(props.onCancel).toHaveBeenCalledOnce();
+ expect(props.onAdd).not.toHaveBeenCalled();
+ expect(props.onToggle).not.toHaveBeenCalled();
+ expect(props.onRemove).not.toHaveBeenCalled();
+ });
+
+ it("edits, clones, selects, reruns, and filters suite cases", async () => {
+ const user = userEvent.setup();
+ const failing: RegexTestCase = {
+ id: "failing",
+ name: "Failing case",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "old",
+ flags: ["i"],
+ options: {},
+ scanAll: true,
+ subject: "subject",
+ expectation: { kind: "should-match" },
+ };
+ const passing: RegexTestCase = {
+ ...failing,
+ id: "passing",
+ name: "Passing case",
+ };
+ const props = renderPanel({
+ tests: [failing, passing],
+ results: new Map([
+ [
+ failing.id,
+ {
+ testId: failing.id,
+ passed: false,
+ elapsedMs: 1,
+ message: "Expected at least one match.",
+ timedOut: false,
+ },
+ ],
+ [
+ passing.id,
+ {
+ testId: passing.id,
+ passed: true,
+ elapsedMs: 1,
+ message: "Matched once.",
+ timedOut: false,
+ },
+ ],
+ ]),
+ });
+
+ await user.click(
+ screen.getByRole("checkbox", { name: "Select Failing case" }),
+ );
+ await user.click(screen.getByRole("button", { name: "Run selected" }));
+ expect(props.onRunSelected).toHaveBeenCalledWith(["failing"]);
+
+ await user.click(
+ screen.getByRole("button", { name: "Clone Failing case" }),
+ );
+ expect(props.onClone).toHaveBeenCalledWith("failing");
+
+ await user.click(screen.getByRole("button", { name: "Edit Failing case" }));
+ expect(
+ screen.getByRole("heading", { name: "Edit unit test" }),
+ ).toBeVisible();
+ await replaceInput(
+ user,
+ screen.getByRole("textbox", { name: "Name" }),
+ "Updated case",
+ );
+ await replaceInput(
+ user,
+ screen.getByRole("textbox", { name: "Pattern" }),
+ "new",
+ );
+ await user.click(screen.getByRole("button", { name: "Update test" }));
+
+ expect(props.onUpdate).toHaveBeenCalledWith(
+ "failing",
+ expect.objectContaining({
+ name: "Updated case",
+ pattern: "new",
+ flags: ["i"],
+ scanAll: true,
+ subject: "subject",
+ expectation: { kind: "should-match" },
+ }),
+ );
+
+ await user.click(screen.getByRole("checkbox", { name: "Failures only" }));
+ expect(screen.getByText("Failing case")).toBeVisible();
+ expect(screen.queryByText("Passing case")).not.toBeInTheDocument();
+ });
+
+ it("adds a bounded snapshot of the latest current result", async () => {
+ const user = userEvent.setup();
+ const currentResultDraft: RegexTestDraft = {
+ name: "Current match result",
+ pattern: "word",
+ flags: ["g"],
+ scanAll: true,
+ subject: "word word",
+ expectation: { kind: "match-count", count: 2 },
+ };
+ const props = renderPanel({ currentResultDraft });
+
+ await user.click(
+ screen.getByRole("button", { name: "Add current result" }),
+ );
+
+ expect(props.onAdd).toHaveBeenCalledWith(currentResultDraft);
+ });
+
+ it("reports an aggregate state-limit rejection from cloning", async () => {
+ const user = userEvent.setup();
+ const test: RegexTestCase = {
+ id: "large",
+ name: "Large case",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "large",
+ flags: [],
+ options: {},
+ subject: "large",
+ expectation: { kind: "should-match" },
+ };
+ renderPanel({
+ tests: [test],
+ onClone: vi.fn(() => {
+ throw new Error("Test suite exceeds the 32 MiB aggregate state limit.");
+ }),
+ });
+
+ await user.click(screen.getByRole("button", { name: "Clone Large case" }));
+
+ expect(screen.getByRole("alert")).toHaveTextContent(
+ "32 MiB aggregate state limit",
+ );
+ });
+
+ it("imports a validated standalone JSON suite", async () => {
+ const imported: RegexTestCase = {
+ id: "imported",
+ name: "Imported case",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "imported",
+ flags: [],
+ options: {},
+ subject: "imported",
+ expectation: { kind: "should-match" },
+ };
+ const serialized = JSON.stringify({
+ schemaVersion: 1,
+ tests: [imported],
+ });
+ const file = new File([serialized], "suite.json", {
+ type: "application/json",
+ });
+ Object.defineProperty(file, "text", {
+ value: () => Promise.resolve(serialized),
+ });
+ const props = renderPanel();
+ const input = screen.getByLabelText("Import test-suite JSON file");
+ expect(input).toHaveAttribute("tabindex", "-1");
+
+ fireEvent.change(input, {
+ target: { files: [file] },
+ });
+
+ await waitFor(() =>
+ expect(props.onImport).toHaveBeenCalledWith([imported]),
+ );
+ expect(screen.getByRole("status")).toHaveTextContent("Imported 1 test.");
+ });
+});
diff --git a/src/components/UnitTestPanel.tsx b/src/components/UnitTestPanel.tsx
new file mode 100644
index 0000000..d483b56
--- /dev/null
+++ b/src/components/UnitTestPanel.tsx
@@ -0,0 +1,1106 @@
+import { useId, useRef, useState } from "react";
+import {
+ DEFAULT_REGEX_LIMITS,
+ utf8ByteLength,
+} from "../regex/execution/request-limits";
+import type { RegexResourceLimits } from "../regex/execution/request-limits";
+import {
+ MAXIMUM_REGEX_TEST_CAPTURE_NAME_UTF16,
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ MAXIMUM_REGEX_TEST_NAME_UTF16,
+ MAXIMUM_REGEX_TEST_PATTERN_UTF16,
+ MAXIMUM_REGEX_TEST_SUBJECT_UTF16,
+ MAXIMUM_REGEX_TESTS,
+} from "../regex/tests/test-case.types";
+import type {
+ RegexTestCase,
+ RegexTestExpectation,
+ RegexTestResult,
+} from "../regex/tests/test-case.types";
+import {
+ importRegexTestSuite,
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
+ serializeRegexTestSuite,
+} from "../regex/tests/test-suite.serialization";
+
+const MAXIMUM_MATCH_COUNT = 1_000_000;
+const MAXIMUM_MATCH_INDEX = 1_000_000;
+const MAXIMUM_CAPTURE_NUMBER = 1_000;
+const MAXIMUM_TIMEOUT_MS = DEFAULT_REGEX_LIMITS.advancedMaximumTimeoutMs;
+const FLAG_OPTIONS = [
+ ["g", "Global"],
+ ["i", "Ignore case"],
+ ["m", "Multiline"],
+ ["s", "Dot all"],
+ ["u", "Unicode"],
+ ["v", "Unicode sets"],
+ ["y", "Sticky"],
+ ["d", "Expose indices"],
+] as const;
+
+type CaptureSelector = "number" | "name";
+type CaptureStatus = "participated" | "did-not-participate" | "matched-empty";
+
+export interface RegexTestDraft {
+ readonly name: string;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly scanAll: boolean;
+ readonly subject: string;
+ readonly expectation: RegexTestExpectation;
+ readonly replacement?: string;
+ readonly resourceLimits?: Readonly<
+ Pick
+ >;
+}
+
+export interface UnitTestPanelProps {
+ readonly tests: readonly RegexTestCase[];
+ readonly results: ReadonlyMap;
+ readonly currentPattern: string;
+ readonly currentFlags: readonly string[];
+ readonly currentScanAll: boolean;
+ readonly currentSubject: string;
+ readonly currentReplacement: string;
+ readonly currentResultDraft?: RegexTestDraft;
+ readonly onAdd: (draft: RegexTestDraft) => void;
+ readonly onUpdate: (id: string, draft: RegexTestDraft) => void;
+ readonly onClone: (id: string) => void;
+ readonly onImport: (tests: readonly RegexTestCase[]) => void;
+ readonly onRemove: (id: string) => void;
+ readonly onToggle: (id: string) => void;
+ readonly onRunAll: () => void;
+ readonly onRunSelected: (ids: readonly string[]) => void;
+ readonly onCancel: () => void;
+ readonly running: boolean;
+}
+
+function boundedInteger(
+ source: string,
+ label: string,
+ minimum: number,
+ maximum: number,
+): number {
+ const value = Number(source);
+ if (
+ source.trim() === "" ||
+ !Number.isSafeInteger(value) ||
+ value < minimum ||
+ value > maximum
+ ) {
+ throw new Error(
+ `${label} must be a whole number from ${minimum.toLocaleString()} to ${maximum.toLocaleString()}.`,
+ );
+ }
+ return value;
+}
+
+function boundedText(value: string, label: string, maximum: number): string {
+ if (value.length > maximum) {
+ throw new Error(
+ `${label} exceeds the ${maximum.toLocaleString()} UTF-16 unit limit.`,
+ );
+ }
+ return value;
+}
+
+function validateDraft(draft: RegexTestDraft): RegexTestDraft {
+ boundedText(draft.name, "Test name", MAXIMUM_REGEX_TEST_NAME_UTF16);
+ boundedText(draft.pattern, "Pattern", MAXIMUM_REGEX_TEST_PATTERN_UTF16);
+ boundedText(draft.subject, "Subject", MAXIMUM_REGEX_TEST_SUBJECT_UTF16);
+ const subjectBytes = utf8ByteLength(draft.subject);
+ if (subjectBytes > DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes) {
+ throw new Error(
+ `Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
+ );
+ }
+ if (new Set(draft.flags).size !== draft.flags.length) {
+ throw new Error("Flags must not contain duplicates.");
+ }
+ if (
+ draft.flags.some(
+ (flag) => !FLAG_OPTIONS.some(([supported]) => supported === flag),
+ )
+ ) {
+ throw new Error("Flags contain an unsupported ECMAScript flag.");
+ }
+ if (draft.flags.includes("u") && draft.flags.includes("v")) {
+ throw new Error("ECMAScript flags u and v cannot be combined.");
+ }
+ if (
+ draft.replacement !== undefined &&
+ draft.replacement.length >
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
+ ) {
+ throw new Error(
+ `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
+ );
+ }
+ switch (draft.expectation.kind) {
+ case "full-match":
+ boundedText(
+ draft.expectation.value,
+ "Expected full match",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ );
+ break;
+ case "capture":
+ if (draft.expectation.groupName !== undefined) {
+ boundedText(
+ draft.expectation.groupName,
+ "Capture name",
+ MAXIMUM_REGEX_TEST_CAPTURE_NAME_UTF16,
+ );
+ }
+ if (draft.expectation.value !== undefined) {
+ boundedText(
+ draft.expectation.value,
+ "Expected capture value",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ );
+ }
+ break;
+ case "replacement":
+ boundedText(
+ draft.expectation.expected,
+ "Expected replacement",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ );
+ break;
+ default:
+ break;
+ }
+ return draft;
+}
+
+function expectationSummary(expectation: RegexTestExpectation): string {
+ switch (expectation.kind) {
+ case "should-match":
+ return "should match";
+ case "should-not-match":
+ return "should not match";
+ case "match-count":
+ return `match count: ${expectation.count}`;
+ case "full-match":
+ return `full match ${expectation.matchIndex}: exact value`;
+ case "capture":
+ return `capture ${
+ expectation.groupName === undefined
+ ? `#${expectation.groupNumber}`
+ : `<${expectation.groupName}>`
+ }: ${expectation.status.replaceAll("-", " ")}`;
+ case "replacement":
+ return "replacement: exact output";
+ case "must-complete-within":
+ return `complete within ${expectation.milliseconds} ms`;
+ case "must-time-out":
+ return "must time out";
+ }
+}
+
+function downloadJson(serialized: string): void {
+ const blob = new Blob([serialized], { type: "application/json" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = "regex-tests.regex-tests.json";
+ anchor.click();
+ window.setTimeout(() => URL.revokeObjectURL(url), 1_000);
+}
+
+export function UnitTestPanel({
+ tests,
+ results,
+ currentPattern,
+ currentFlags,
+ currentScanAll,
+ currentSubject,
+ currentReplacement,
+ currentResultDraft,
+ onAdd,
+ onUpdate,
+ onClone,
+ onImport,
+ onRemove,
+ onToggle,
+ onRunAll,
+ onRunSelected,
+ onCancel,
+ running,
+}: UnitTestPanelProps) {
+ const helpId = useId();
+ const errorId = useId();
+ const formId = useId();
+ const importInput = useRef(null);
+ const [editingId, setEditingId] = useState();
+ const [name, setName] = useState("Current example");
+ const [pattern, setPattern] = useState(currentPattern);
+ const [flags, setFlags] = useState(currentFlags);
+ const [scanAll, setScanAll] = useState(currentScanAll);
+ const [subject, setSubject] = useState(currentSubject);
+ const [expectationKind, setExpectationKind] =
+ useState("should-match");
+ const [matchCount, setMatchCount] = useState("1");
+ const [matchIndex, setMatchIndex] = useState("0");
+ const [expectedFullMatch, setExpectedFullMatch] = useState("");
+ const [captureSelector, setCaptureSelector] =
+ useState("number");
+ const [captureNumber, setCaptureNumber] = useState("1");
+ const [captureName, setCaptureName] = useState("");
+ const [captureStatus, setCaptureStatus] =
+ useState("participated");
+ const [assertCaptureValue, setAssertCaptureValue] = useState(false);
+ const [expectedCaptureValue, setExpectedCaptureValue] = useState("");
+ const [includeReplacement, setIncludeReplacement] = useState(false);
+ const [replacement, setReplacement] = useState(currentReplacement);
+ const [expectedReplacement, setExpectedReplacement] = useState("");
+ const [completionMilliseconds, setCompletionMilliseconds] = useState("100");
+ const [timeoutMilliseconds, setTimeoutMilliseconds] = useState("");
+ const [formError, setFormError] = useState();
+ const [selectedIds, setSelectedIds] = useState>(
+ new Set(),
+ );
+ const [failuresOnly, setFailuresOnly] = useState(false);
+ const [includeSubjects, setIncludeSubjects] = useState(false);
+ const [suiteNotice, setSuiteNotice] = useState();
+ const [suiteError, setSuiteError] = useState();
+
+ const maximumReached = tests.length >= MAXIMUM_REGEX_TESTS;
+ const runnableSelected = tests
+ .filter((test) => test.enabled && selectedIds.has(test.id))
+ .map((test) => test.id);
+ const visibleTests = failuresOnly
+ ? tests.filter((test) => results.get(test.id)?.passed === false)
+ : tests;
+
+ function createExpectation(): RegexTestExpectation {
+ switch (expectationKind) {
+ case "should-match":
+ case "should-not-match":
+ case "must-time-out":
+ return { kind: expectationKind };
+ case "match-count":
+ return {
+ kind: expectationKind,
+ count: boundedInteger(
+ matchCount,
+ "Expected match count",
+ 0,
+ MAXIMUM_MATCH_COUNT,
+ ),
+ };
+ case "full-match":
+ return {
+ kind: expectationKind,
+ matchIndex: boundedInteger(
+ matchIndex,
+ "Match index",
+ 0,
+ MAXIMUM_MATCH_INDEX,
+ ),
+ value: expectedFullMatch,
+ };
+ case "capture": {
+ const selector =
+ captureSelector === "number"
+ ? {
+ groupNumber: boundedInteger(
+ captureNumber,
+ "Capture number",
+ 1,
+ MAXIMUM_CAPTURE_NUMBER,
+ ),
+ }
+ : captureName.trim() === ""
+ ? (() => {
+ throw new Error("Capture name must not be empty.");
+ })()
+ : { groupName: captureName.trim() };
+ return {
+ kind: expectationKind,
+ matchIndex: boundedInteger(
+ matchIndex,
+ "Match index",
+ 0,
+ MAXIMUM_MATCH_INDEX,
+ ),
+ ...selector,
+ status: captureStatus,
+ ...(assertCaptureValue ? { value: expectedCaptureValue } : {}),
+ };
+ }
+ case "replacement":
+ return {
+ kind: expectationKind,
+ expected: expectedReplacement,
+ };
+ case "must-complete-within":
+ return {
+ kind: expectationKind,
+ milliseconds: boundedInteger(
+ completionMilliseconds,
+ "Completion limit",
+ 1,
+ MAXIMUM_TIMEOUT_MS,
+ ),
+ };
+ }
+ }
+
+ function makeDraft(): RegexTestDraft {
+ const timeout =
+ timeoutMilliseconds.trim() === ""
+ ? undefined
+ : boundedInteger(
+ timeoutMilliseconds,
+ "Per-test timeout",
+ 1,
+ MAXIMUM_TIMEOUT_MS,
+ );
+ return validateDraft({
+ name: name.trim() || "Untitled test",
+ pattern,
+ flags,
+ scanAll,
+ subject,
+ expectation: createExpectation(),
+ ...(expectationKind === "replacement" && includeReplacement
+ ? { replacement }
+ : {}),
+ ...(timeout === undefined
+ ? {}
+ : {
+ resourceLimits: {
+ manualExecutionTimeoutMs: timeout,
+ },
+ }),
+ });
+ }
+
+ function submitTest(): void {
+ if (running) return;
+ try {
+ if (!editingId && maximumReached) {
+ throw new Error(
+ `A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
+ );
+ }
+ const draft = makeDraft();
+ if (editingId) {
+ onUpdate(editingId, draft);
+ setEditingId(undefined);
+ } else {
+ onAdd(draft);
+ }
+ setFormError(undefined);
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ function loadDraft(draft: RegexTestDraft, id?: string): void {
+ setEditingId(id);
+ setName(draft.name);
+ setPattern(draft.pattern);
+ setFlags(draft.flags);
+ setScanAll(draft.scanAll);
+ setSubject(draft.subject);
+ setExpectationKind(draft.expectation.kind);
+ setMatchCount(
+ draft.expectation.kind === "match-count"
+ ? String(draft.expectation.count)
+ : "1",
+ );
+ setMatchIndex(
+ draft.expectation.kind === "full-match" ||
+ draft.expectation.kind === "capture"
+ ? String(draft.expectation.matchIndex)
+ : "0",
+ );
+ setExpectedFullMatch(
+ draft.expectation.kind === "full-match" ? draft.expectation.value : "",
+ );
+ setCaptureSelector(
+ draft.expectation.kind === "capture" &&
+ draft.expectation.groupName !== undefined
+ ? "name"
+ : "number",
+ );
+ setCaptureNumber(
+ draft.expectation.kind === "capture" &&
+ draft.expectation.groupNumber !== undefined
+ ? String(draft.expectation.groupNumber)
+ : "1",
+ );
+ setCaptureName(
+ draft.expectation.kind === "capture"
+ ? (draft.expectation.groupName ?? "")
+ : "",
+ );
+ setCaptureStatus(
+ draft.expectation.kind === "capture"
+ ? draft.expectation.status
+ : "participated",
+ );
+ setAssertCaptureValue(
+ draft.expectation.kind === "capture" &&
+ draft.expectation.value !== undefined,
+ );
+ setExpectedCaptureValue(
+ draft.expectation.kind === "capture"
+ ? (draft.expectation.value ?? "")
+ : "",
+ );
+ setIncludeReplacement(draft.replacement !== undefined);
+ setReplacement(draft.replacement ?? currentReplacement);
+ setExpectedReplacement(
+ draft.expectation.kind === "replacement"
+ ? draft.expectation.expected
+ : "",
+ );
+ setCompletionMilliseconds(
+ draft.expectation.kind === "must-complete-within"
+ ? String(draft.expectation.milliseconds)
+ : "100",
+ );
+ setTimeoutMilliseconds(
+ draft.resourceLimits?.manualExecutionTimeoutMs === undefined
+ ? ""
+ : String(draft.resourceLimits.manualExecutionTimeoutMs),
+ );
+ setFormError(undefined);
+ }
+
+ function editTest(test: RegexTestCase): void {
+ loadDraft(
+ {
+ name: test.name,
+ pattern: test.pattern,
+ flags: test.flags,
+ scanAll: test.scanAll ?? false,
+ subject: test.subject,
+ expectation: test.expectation,
+ ...(test.replacement === undefined
+ ? {}
+ : { replacement: test.replacement }),
+ ...(test.resourceLimits?.manualExecutionTimeoutMs === undefined
+ ? {}
+ : {
+ resourceLimits: {
+ manualExecutionTimeoutMs:
+ test.resourceLimits.manualExecutionTimeoutMs,
+ },
+ }),
+ },
+ test.id,
+ );
+ }
+
+ function addCurrentResult(): void {
+ if (running || maximumReached || !currentResultDraft) return;
+ try {
+ onAdd(validateDraft(currentResultDraft));
+ setFormError(undefined);
+ } catch (error) {
+ setFormError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ async function importSuite(file: File): Promise {
+ setSuiteError(undefined);
+ setSuiteNotice(undefined);
+ try {
+ if (file.size > MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES) {
+ throw new Error(
+ `Test-suite JSON exceeds the ${(MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES / 1024 / 1024).toLocaleString()} MiB file limit.`,
+ );
+ }
+ const imported = importRegexTestSuite(await file.text());
+ if (tests.length + imported.tests.length > MAXIMUM_REGEX_TESTS) {
+ throw new Error(
+ `Import would exceed the ${MAXIMUM_REGEX_TESTS.toLocaleString()} test limit.`,
+ );
+ }
+ onImport(imported.tests);
+ setSuiteNotice(
+ `Imported ${imported.tests.length.toLocaleString()} test${
+ imported.tests.length === 1 ? "" : "s"
+ }.`,
+ );
+ } catch (error) {
+ setSuiteError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ function exportSuite(): void {
+ setSuiteError(undefined);
+ setSuiteNotice(undefined);
+ try {
+ downloadJson(serializeRegexTestSuite(tests, includeSubjects));
+ setSuiteNotice(
+ `Exported ${tests.length.toLocaleString()} test${
+ tests.length === 1 ? "" : "s"
+ }${includeSubjects ? " with subjects" : " without subjects"}.`,
+ );
+ } catch (error) {
+ setSuiteError(error instanceof Error ? error.message : String(error));
+ }
+ }
+
+ return (
+
+
+
+
+
Versioned project tests
+
{editingId ? "Edit unit test" : "Add unit test"}
+
+
+
+
+
+ {editingId ? "Update test" : "Add test"}
+
+ {editingId ? (
+ setEditingId(undefined)}
+ >
+ Cancel edit
+
+ ) : null}
+
+ Add current result
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/Workbench.test.tsx b/src/components/Workbench.test.tsx
new file mode 100644
index 0000000..70bb77e
--- /dev/null
+++ b/src/components/Workbench.test.tsx
@@ -0,0 +1,383 @@
+import {
+ act,
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+} from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { RegexExecutionRequest } from "../regex/model/match";
+import type {
+ CaptureDefinition,
+ RegexSyntaxRequest,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+} from "../regex/model/syntax";
+
+interface PendingPattern {
+ readonly request: RegexSyntaxRequest;
+ readonly resolve: (result: RegexSyntaxResult) => void;
+}
+
+interface PendingReplacement {
+ readonly replacement: string;
+ readonly resolve: (result: ReplacementSyntaxResult) => void;
+}
+
+interface SyntaxInstance {
+ readonly label: string;
+ cancelCalls: number;
+}
+
+const syntaxHarness = vi.hoisted(() => ({
+ instances: [] as SyntaxInstance[],
+ patterns: [] as PendingPattern[],
+ replacements: [] as PendingReplacement[],
+}));
+
+const engineHarness = vi.hoisted(() => ({
+ requests: [] as RegexExecutionRequest[],
+}));
+
+vi.mock("../editors/CodeEditor", () => ({
+ CodeEditor({
+ value,
+ onChange,
+ label,
+ readOnly,
+ }: {
+ readonly value: string;
+ readonly onChange?: (value: string) => void;
+ readonly label: string;
+ readonly readOnly?: boolean;
+ }) {
+ return (
+ onChange?.(event.target.value)}
+ />
+ );
+ },
+}));
+
+vi.mock("../regex/execution/SyntaxSupervisor", () => ({
+ SyntaxSupervisor: class {
+ readonly instance: SyntaxInstance;
+
+ constructor(label = "Syntax parser") {
+ this.instance = { label, cancelCalls: 0 };
+ syntaxHarness.instances.push(this.instance);
+ }
+
+ parsePattern(request: RegexSyntaxRequest): Promise {
+ return new Promise((resolve) => {
+ syntaxHarness.patterns.push({ request, resolve });
+ });
+ }
+
+ parseReplacement(request: {
+ readonly replacement: string;
+ }): Promise {
+ return new Promise((resolve) => {
+ syntaxHarness.replacements.push({
+ replacement: request.replacement,
+ resolve,
+ });
+ });
+ }
+
+ cancel(): void {
+ this.instance.cancelCalls += 1;
+ }
+
+ dispose(): void {}
+ },
+}));
+
+vi.mock("../regex/execution/EngineSupervisor", () => ({
+ EngineSupervisor: class {
+ result(request: RegexExecutionRequest) {
+ return {
+ accepted: true,
+ engine: {
+ id: "test-engine",
+ displayName: "Test engine",
+ version: "1",
+ flavour: "ecmascript",
+ offsetUnit: "utf16",
+ runtime: "worker",
+ },
+ flags: {
+ userFlags: request.flags.join(""),
+ effectiveFlags: request.flags.join(""),
+ internallyAddedIndicesFlag: false,
+ internallyAddedGlobalFlag: false,
+ },
+ matches: [],
+ diagnostics: [],
+ elapsedMs: 1,
+ truncated: false,
+ };
+ }
+
+ execute(request: RegexExecutionRequest) {
+ engineHarness.requests.push(request);
+ return Promise.resolve(this.result(request));
+ }
+
+ replace(
+ request: RegexExecutionRequest & {
+ readonly replacement: string;
+ },
+ ) {
+ engineHarness.requests.push(request);
+ return Promise.resolve({
+ execution: this.result(request),
+ output: request.replacement,
+ outputBytes: request.replacement.length,
+ outputTruncated: false,
+ truncated: false,
+ });
+ }
+
+ cancel(): void {}
+
+ dispose(): void {}
+ },
+}));
+
+import { Workbench } from "./Workbench";
+
+function syntaxResult(
+ pattern: string,
+ captures: readonly CaptureDefinition[],
+): RegexSyntaxResult {
+ return {
+ accepted: true,
+ root: {
+ id: `root-${pattern}`,
+ kind: "pattern",
+ range: { startUtf16: 0, endUtf16: pattern.length },
+ raw: pattern,
+ explanation: "Test syntax",
+ children: [],
+ properties: {
+ zeroWidth: false,
+ nullable: false,
+ consumesInput: true,
+ },
+ support: {
+ flavour: "ecmascript",
+ status: "supported",
+ notes: [],
+ },
+ provenance: {
+ provider: "test",
+ providerVersion: "1",
+ source: "parsed",
+ },
+ },
+ tokens: [],
+ captures,
+ diagnostics: [],
+ provider: { id: "test", version: "1" },
+ coverage: {
+ status: "full-tested",
+ summary: "Test coverage",
+ unsupportedConstructs: [],
+ },
+ };
+}
+
+async function advanceParseDebounce(): Promise {
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(120);
+ });
+}
+
+async function resolvePattern(
+ index: number,
+ captures: readonly CaptureDefinition[],
+): Promise {
+ await act(async () => {
+ const pending = syntaxHarness.patterns[index];
+ if (!pending) throw new Error(`Pattern request ${index} is unavailable`);
+ pending.resolve(syntaxResult(pending.request.pattern, captures));
+ await Promise.resolve();
+ });
+}
+
+async function resolveLatestReplacement(): Promise {
+ await act(async () => {
+ const pending = syntaxHarness.replacements.at(-1);
+ if (!pending) throw new Error("Replacement request is unavailable");
+ pending.resolve({
+ accepted: true,
+ tokens: [],
+ diagnostics: [],
+ totalDiagnostics: 0,
+ diagnosticsTruncated: false,
+ });
+ await Promise.resolve();
+ });
+}
+
+afterEach(() => {
+ cleanup();
+ vi.useRealTimers();
+ syntaxHarness.instances.length = 0;
+ syntaxHarness.patterns.length = 0;
+ syntaxHarness.replacements.length = 0;
+ engineHarness.requests.length = 0;
+});
+
+describe("Workbench syntax coordination", () => {
+ it("blocks Run until current syntax arrives and isolates replacement parsing", async () => {
+ vi.useFakeTimers();
+ render( );
+ fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
+
+ await advanceParseDebounce();
+ await resolvePattern(0, [
+ {
+ number: 1,
+ name: "old",
+ range: { startUtf16: 0, endUtf16: 9 },
+ repeated: false,
+ },
+ ]);
+
+ expect(syntaxHarness.instances.map((instance) => instance.label)).toEqual([
+ "Pattern syntax parser",
+ "Replacement syntax parser",
+ ]);
+
+ fireEvent.click(screen.getByRole("button", { name: "Replace" }));
+ const patternInput = screen.getByRole("textbox", {
+ name: "Regular expression pattern",
+ });
+ const replacementInput = screen.getByRole("textbox", {
+ name: "Replacement template",
+ });
+
+ fireEvent.change(patternInput, { target: { value: "(?b)" } });
+ fireEvent.change(replacementInput, { target: { value: "$" } });
+
+ const runButton = screen.getByRole("button", { name: "Run" });
+ expect(runButton).toBeDisabled();
+ fireEvent.click(runButton);
+ expect(engineHarness.requests).toHaveLength(0);
+
+ await advanceParseDebounce();
+ expect(syntaxHarness.patterns[1]?.request.pattern).toBe("(?b)");
+ await resolvePattern(1, [
+ {
+ number: 1,
+ name: "new",
+ range: { startUtf16: 0, endUtf16: 9 },
+ repeated: false,
+ },
+ ]);
+
+ expect(runButton).toBeDisabled();
+ await resolveLatestReplacement();
+ expect(runButton).toBeEnabled();
+ const patternParser = syntaxHarness.instances[0];
+ const replacementParser = syntaxHarness.instances[1];
+ if (!patternParser || !replacementParser) {
+ throw new Error("Expected both syntax parser instances");
+ }
+ const patternCancelCount = patternParser.cancelCalls;
+ fireEvent.change(replacementInput, { target: { value: "$!" } });
+ fireEvent.change(replacementInput, { target: { value: "[$]" } });
+
+ expect(patternParser.cancelCalls).toBe(patternCancelCount);
+ expect(replacementParser.cancelCalls).toBeGreaterThan(0);
+ expect(
+ syntaxHarness.replacements.map((request) => request.replacement),
+ ).toContain("[$]");
+
+ expect(runButton).toBeDisabled();
+ await resolveLatestReplacement();
+ expect(runButton).toBeEnabled();
+ fireEvent.click(runButton);
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(engineHarness.requests).toHaveLength(1);
+ expect(engineHarness.requests[0]).toMatchObject({
+ pattern: "(?b)",
+ captureMetadata: [{ number: 1, name: "new" }],
+ });
+ });
+
+ it("captures a complete result and reruns only the selected saved test", async () => {
+ vi.useFakeTimers();
+ render( );
+ fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
+
+ await advanceParseDebounce();
+ await resolvePattern(0, []);
+ fireEvent.click(screen.getByRole("button", { name: "Run" }));
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ fireEvent.click(screen.getByRole("button", { name: "Unit tests" }));
+ const addCurrent = screen.getByRole("button", {
+ name: "Add current result",
+ });
+ expect(addCurrent).toBeEnabled();
+ fireEvent.click(addCurrent);
+
+ expect(screen.getByText("Current match result")).toBeVisible();
+ expect(screen.getByText("match count: 0", { exact: false })).toBeVisible();
+ fireEvent.click(
+ screen.getByRole("checkbox", { name: "Select Current match result" }),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Run selected" }));
+
+ const testPatternIndex = syntaxHarness.patterns.length - 1;
+ await resolvePattern(testPatternIndex, []);
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(screen.getByText(/^Pass —/)).toBeVisible();
+ expect(engineHarness.requests.at(-1)?.pattern).toBe(
+ syntaxHarness.patterns[0]?.request.pattern,
+ );
+ expect(engineHarness.requests.at(-1)?.scanAll).toBe(false);
+ });
+
+ it("hard-stops an active suite at the aggregate wall deadline", async () => {
+ vi.useFakeTimers();
+ render( );
+ fireEvent.click(screen.getByRole("checkbox", { name: "Live" }));
+
+ await advanceParseDebounce();
+ await resolvePattern(0, []);
+ fireEvent.click(screen.getByRole("button", { name: "Run" }));
+ await act(async () => {
+ await Promise.resolve();
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Unit tests" }));
+ fireEvent.click(screen.getByRole("button", { name: "Add current result" }));
+ fireEvent.click(screen.getByRole("button", { name: "Run all" }));
+ expect(screen.getByRole("button", { name: "Cancel" })).toBeVisible();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(60_000);
+ });
+
+ expect(screen.getByText(/Suite stopped at the 60 second/u)).toBeVisible();
+ expect(screen.getByRole("button", { name: "Run all" })).toBeEnabled();
+ expect(
+ syntaxHarness.instances.find(
+ (instance) => instance.label === "Syntax parser",
+ )?.cancelCalls,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
new file mode 100644
index 0000000..4658623
--- /dev/null
+++ b/src/components/Workbench.tsx
@@ -0,0 +1,1809 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { CodeEditor } from "../editors/CodeEditor";
+import type { EditorMark } from "../editors/editor.types";
+import {
+ DEFAULT_REGEX_LIMITS,
+ utf8ByteLength,
+} from "../regex/execution/request-limits";
+import { EngineSupervisor } from "../regex/execution/EngineSupervisor";
+import { SyntaxSupervisor } from "../regex/execution/SyntaxSupervisor";
+import { WorkerRequestError } from "../regex/execution/WorkerSupervisor";
+import type { RegexDiagnostic } from "../regex/model/diagnostics";
+import type {
+ ExtractionNode,
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementResult,
+} from "../regex/model/match";
+import type {
+ NormalizedRegexNode,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+ ReplacementToken,
+ SourceRange,
+} from "../regex/model/syntax";
+import {
+ isPatternSyntaxCurrent,
+ isPatternSyntaxForInput,
+ type PatternSyntaxSnapshot,
+} from "../regex/syntax/pattern-syntax-freshness";
+import {
+ buildReplacementPreview,
+ type ReplacementContributionPreview,
+} from "../regex/replacement/replacement-preview";
+import { buildExtractionTree } from "../regex/extraction/build-extraction-tree";
+import {
+ buildCaptureRows,
+ type CaptureRow,
+} from "../regex/extraction/capture-rows";
+import { parseListTemplate } from "../regex/list/list-template";
+import {
+ MAXIMUM_REGEX_TEST_NAME_UTF16,
+ MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+ MAXIMUM_REGEX_TESTS,
+} from "../regex/tests/test-case.types";
+import type {
+ RegexTestCase,
+ RegexTestResult,
+} from "../regex/tests/test-case.types";
+import {
+ boundedTestResultMessage,
+ runRegexTest,
+} from "../regex/tests/test-runner";
+import { chooseReplacementResultDraft } from "../regex/tests/current-result-draft";
+import { assertRegexTestSuiteWithinLimit } from "../regex/tests/test-suite.serialization";
+import type { RegexProjectV1, WorkspaceMode } from "../project/project.types";
+import {
+ saveProjectLocally,
+ loadMostRecentProject,
+} from "../project/persistence";
+import { ExplanationTree } from "./ExplanationTree";
+import { ExtractionTree } from "./ExtractionTree";
+import { CaptureTable } from "./CaptureTable";
+import { DiagnosticsPanel } from "./DiagnosticsPanel";
+import { ReplacementPanel } from "./ReplacementPanel";
+import { ListPanel } from "./ListPanel";
+import { UnitTestPanel, type RegexTestDraft } from "./UnitTestPanel";
+import { QuickReference } from "./QuickReference";
+import { CapabilityPanel } from "./CapabilityPanel";
+import { ProjectPanel } from "./ProjectPanel";
+
+const INITIAL_PATTERN =
+ "(?\\d{4}-\\d{2}-\\d{2})\\s+(?[\\p{Letter}._-]+)";
+const INITIAL_SUBJECT = `2026-07-24 alice
+Invalid row
+2025-12-31 Bérénice`;
+const INITIAL_REPLACEMENT = "$ — $";
+const MAXIMUM_PATTERN_EDITOR_MARKS = 2_000;
+const MAXIMUM_SUBJECT_EDITOR_MARKS = 2_000;
+const MAXIMUM_TEST_SUITE_WALL_TIME_MS = 60_000;
+const FLAG_OPTIONS = [
+ ["g", "Global"],
+ ["i", "Ignore case"],
+ ["m", "Multiline"],
+ ["s", "Dot all"],
+ ["u", "Unicode"],
+ ["v", "Unicode sets"],
+ ["y", "Sticky"],
+ ["d", "Expose indices"],
+] as const;
+
+type RunState =
+ | { readonly status: "idle"; readonly message: string }
+ | { readonly status: "running"; readonly message: string }
+ | { readonly status: "ready"; readonly message: string }
+ | { readonly status: "timeout"; readonly message: string }
+ | { readonly status: "error"; readonly message: string };
+
+function findSmallestNode(
+ node: NormalizedRegexNode,
+ range: SourceRange,
+): NormalizedRegexNode | undefined {
+ if (
+ node.range.startUtf16 > range.startUtf16 ||
+ node.range.endUtf16 < range.endUtf16
+ ) {
+ return undefined;
+ }
+ for (const child of node.children) {
+ const nested = findSmallestNode(child, range);
+ if (nested) return nested;
+ }
+ return node;
+}
+
+function findSmallestExtractionNode(
+ nodes: readonly ExtractionNode[],
+ range: SourceRange,
+): ExtractionNode | undefined {
+ const isCollapsed = range.startUtf16 === range.endUtf16;
+ let smallest: ExtractionNode | undefined;
+ for (const node of nodes) {
+ if (!node.range) continue;
+ const contains = isCollapsed
+ ? node.range.startUtf16 === node.range.endUtf16
+ ? node.range.startUtf16 === range.startUtf16
+ : node.range.startUtf16 <= range.startUtf16 &&
+ range.startUtf16 < node.range.endUtf16
+ : node.range.startUtf16 <= range.startUtf16 &&
+ node.range.endUtf16 >= range.endUtf16;
+ if (!contains) continue;
+
+ const candidate = findSmallestExtractionNode(node.children, range) ?? node;
+ if (!candidate.range) continue;
+ const candidateLength =
+ candidate.range.endUtf16 - candidate.range.startUtf16;
+ const smallestLength = smallest?.range
+ ? smallest.range.endUtf16 - smallest.range.startUtf16
+ : Number.POSITIVE_INFINITY;
+ if (
+ candidateLength < smallestLength ||
+ (candidateLength === smallestLength &&
+ candidate.groupNumber !== undefined &&
+ smallest?.groupNumber === undefined)
+ ) {
+ smallest = candidate;
+ }
+ }
+ return smallest;
+}
+
+function findExtractionNodeById(
+ nodes: readonly ExtractionNode[],
+ id: string,
+): ExtractionNode | undefined {
+ for (const node of nodes) {
+ if (node.id === id) return node;
+ const nested = findExtractionNodeById(node.children, id);
+ if (nested) return nested;
+ }
+ return undefined;
+}
+
+interface PatternMarkSummary {
+ readonly marks: readonly EditorMark[];
+ readonly renderedTokenMarks: number;
+ readonly totalTokenMarks: number;
+ readonly truncated: boolean;
+}
+
+function syntaxMarks(
+ syntax: RegexSyntaxResult | undefined,
+ selected: NormalizedRegexNode | undefined,
+): PatternMarkSummary {
+ const totalTokenMarks = syntax?.tokens.length ?? 0;
+ const capturesByNumber = new Map(
+ syntax?.captures.map((capture) => [capture.number, capture]) ?? [],
+ );
+ const tokenMarks =
+ syntax?.tokens.slice(0, MAXIMUM_PATTERN_EDITOR_MARKS).map((token) => {
+ const capture =
+ token.captureNumber === undefined
+ ? undefined
+ : capturesByNumber.get(token.captureNumber);
+ return {
+ id: token.id,
+ range: token.range,
+ className: [
+ "cm-syntax-token",
+ `cm-token-${token.kind}`,
+ token.captureNumber
+ ? `cm-capture-${((token.captureNumber - 1) % 8) + 1}`
+ : "",
+ ]
+ .filter(Boolean)
+ .join(" "),
+ label: capture
+ ? `${token.kind.replaceAll("-", " ")} · group ${capture.number}${
+ capture.name ? ` · ${capture.name}` : ""
+ }`
+ : token.kind.replaceAll("-", " "),
+ };
+ }) ?? [];
+ const diagnosticMarks =
+ syntax?.diagnostics
+ .filter(
+ (diagnostic): diagnostic is RegexDiagnostic & { range: SourceRange } =>
+ diagnostic.range !== undefined,
+ )
+ .map((diagnostic) => ({
+ id: `diagnostic-${diagnostic.id}`,
+ range: diagnostic.range,
+ className: `cm-diagnostic-${diagnostic.severity}`,
+ label: diagnostic.message,
+ })) ?? [];
+ return {
+ marks: [
+ ...tokenMarks,
+ ...diagnosticMarks,
+ ...(selected
+ ? [
+ {
+ id: "selected-syntax",
+ range: selected.range,
+ className: "cm-selected-range",
+ label: selected.explanation,
+ },
+ ]
+ : []),
+ ],
+ renderedTokenMarks: tokenMarks.length,
+ totalTokenMarks,
+ truncated: tokenMarks.length < totalTokenMarks,
+ };
+}
+
+interface SubjectMarkSummary {
+ readonly marks: readonly EditorMark[];
+ readonly renderedResultMarks: number;
+ readonly totalResultMarks: number;
+ readonly truncated: boolean;
+}
+
+function subjectMarks(
+ execution: RegexExecutionResult | undefined,
+ selectedRange: SourceRange | undefined,
+): SubjectMarkSummary {
+ if (!execution) {
+ return {
+ marks: [],
+ renderedResultMarks: 0,
+ totalResultMarks: 0,
+ truncated: false,
+ };
+ }
+ const marks: EditorMark[] = [];
+ const resultMarkLimit = Math.max(
+ 0,
+ MAXIMUM_SUBJECT_EDITOR_MARKS - (selectedRange ? 1 : 0),
+ );
+ let totalResultMarks = 0;
+ const addResultMark = (mark: EditorMark) => {
+ totalResultMarks += 1;
+ if (marks.length < resultMarkLimit) marks.push(mark);
+ };
+ for (const match of execution.matches) {
+ addResultMark({
+ id: `subject-match-${match.matchNumber}`,
+ range: match.range,
+ className: "cm-subject-match",
+ label: `Match ${match.matchNumber}`,
+ zeroWidth: match.range.startUtf16 === match.range.endUtf16,
+ });
+ for (const capture of match.captures) {
+ if (!capture.range) continue;
+ addResultMark({
+ id: `subject-capture-${match.matchNumber}-${capture.groupNumber}`,
+ range: capture.range,
+ className: `cm-subject-capture cm-capture-${
+ ((capture.groupNumber - 1) % 8) + 1
+ }`,
+ label: `Match ${match.matchNumber}, capture ${capture.groupNumber}`,
+ zeroWidth: capture.range.startUtf16 === capture.range.endUtf16,
+ });
+ }
+ }
+ const renderedResultMarks = marks.length;
+ if (selectedRange) {
+ marks.push({
+ id: "selected-subject",
+ range: selectedRange,
+ className: "cm-selected-range",
+ label: "Selected result",
+ zeroWidth: selectedRange.startUtf16 === selectedRange.endUtf16,
+ });
+ }
+ return {
+ marks,
+ renderedResultMarks,
+ totalResultMarks,
+ truncated: renderedResultMarks < totalResultMarks,
+ };
+}
+
+let fallbackIdSequence = 0;
+
+function projectId(): string {
+ return (
+ globalThis.crypto?.randomUUID?.() ??
+ `regex-${Date.now()}-${(fallbackIdSequence += 1)}`
+ );
+}
+
+function testCaseFromDraft(
+ id: string,
+ enabled: boolean,
+ draft: RegexTestDraft,
+): RegexTestCase {
+ return {
+ id,
+ name: draft.name,
+ enabled,
+ flavour: "ecmascript",
+ flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
+ pattern: draft.pattern,
+ flags: draft.flags,
+ options: {},
+ scanAll: draft.scanAll,
+ subject: draft.subject,
+ ...(draft.replacement === undefined
+ ? {}
+ : { replacement: draft.replacement }),
+ expectation: draft.expectation,
+ ...(draft.resourceLimits === undefined
+ ? {}
+ : { resourceLimits: draft.resourceLimits }),
+ };
+}
+
+function captureGroupLimitMessage(count: number): string {
+ return `Pattern defines ${count.toLocaleString()} capture groups, above the ${DEFAULT_REGEX_LIMITS.maximumCaptureGroups.toLocaleString()} group execution limit.`;
+}
+
+function engineRejectionMessage(result: RegexExecutionResult): string {
+ const detail =
+ result.diagnostics.find((diagnostic) => diagnostic.severity === "error")
+ ?.message ?? "The execution engine could not compile the pattern.";
+ return `The syntax provider accepted the pattern, but the execution engine rejected it: ${detail}`;
+}
+
+function failedTestResult(testId: string, message: string): RegexTestResult {
+ return {
+ testId,
+ passed: false,
+ elapsedMs: 0,
+ message: boundedTestResultMessage(
+ message,
+ MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+ ),
+ timedOut: false,
+ };
+}
+
+export function Workbench() {
+ const [pattern, setPattern] = useState(INITIAL_PATTERN);
+ const [flags, setFlags] = useState(["g", "u"]);
+ const [subject, setSubject] = useState(INITIAL_SUBJECT);
+ const [replacement, setReplacement] = useState(INITIAL_REPLACEMENT);
+ const [listTemplate, setListTemplate] = useState("${user} — ${date}");
+ const [mode, setMode] = useState("match");
+ const [live, setLive] = useState(true);
+ const [scanAll, setScanAll] = useState(false);
+ const [timeoutMs, setTimeoutMs] = useState(
+ DEFAULT_REGEX_LIMITS.manualExecutionTimeoutMs,
+ );
+ const [syntaxSnapshot, setSyntaxSnapshot] =
+ useState>();
+ const [replacementSyntax, setReplacementSyntax] =
+ useState();
+ const [execution, setExecution] = useState();
+ const [replacementResult, setReplacementResult] =
+ useState();
+ const [selectedSyntax, setSelectedSyntax] = useState();
+ const [requestedPatternRange, setRequestedPatternRange] =
+ useState();
+ const [requestedSubjectRange, setRequestedSubjectRange] =
+ useState();
+ const [selectedExtraction, setSelectedExtraction] =
+ useState();
+ const [selectedCaptureRow, setSelectedCaptureRow] = useState();
+ const [selectedReplacementToken, setSelectedReplacementToken] =
+ useState();
+ const [selectedReplacementContribution, setSelectedReplacementContribution] =
+ useState();
+ const [requestedOutputRange, setRequestedOutputRange] =
+ useState();
+ const [runState, setRunState] = useState({
+ status: "idle",
+ message: "Ready for local execution.",
+ });
+ const [tests, setTests] = useState([]);
+ const [testResults, setTestResults] = useState<
+ ReadonlyMap
+ >(new Map());
+ const [currentResultDraft, setCurrentResultDraft] =
+ useState();
+ const [testsRunning, setTestsRunning] = useState(false);
+ const [showReference, setShowReference] = useState(false);
+ const [showCapabilities, setShowCapabilities] = useState(false);
+ const [showProject, setShowProject] = useState(false);
+ const [showSubjectWhitespace, setShowSubjectWhitespace] = useState(false);
+ const [subjectCursor, setSubjectCursor] = useState({ line: 1, column: 1 });
+ const [projectIdentity, setProjectIdentity] = useState(() => ({
+ id: projectId(),
+ createdAt: new Date().toISOString(),
+ }));
+ const patternSyntaxSupervisor = useRef(null);
+ const replacementSyntaxSupervisor = useRef(null);
+ const engineSupervisor = useRef(null);
+ const testSyntaxSupervisor = useRef(null);
+ const testEngineSupervisor = useRef(null);
+ const syntaxRevision = useRef(0);
+ const replacementSyntaxRevision = useRef(0);
+ const executionRevision = useRef(0);
+ const testRunRevision = useRef(0);
+ const liveExecutionTimer = useRef(undefined);
+
+ const patternSyntaxEngine = () => {
+ patternSyntaxSupervisor.current ??= new SyntaxSupervisor(
+ "Pattern syntax parser",
+ "regex-tools-pattern-syntax",
+ );
+ return patternSyntaxSupervisor.current;
+ };
+ const replacementSyntaxEngine = () => {
+ replacementSyntaxSupervisor.current ??= new SyntaxSupervisor(
+ "Replacement syntax parser",
+ "regex-tools-replacement-syntax",
+ );
+ return replacementSyntaxSupervisor.current;
+ };
+ const executionEngine = () => {
+ engineSupervisor.current ??= new EngineSupervisor();
+ return engineSupervisor.current;
+ };
+ const testSyntaxEngine = () => {
+ testSyntaxSupervisor.current ??= new SyntaxSupervisor();
+ return testSyntaxSupervisor.current;
+ };
+ const testExecutionEngine = () => {
+ testEngineSupervisor.current ??= new EngineSupervisor();
+ return testEngineSupervisor.current;
+ };
+
+ const clearExecutionResults = useCallback(() => {
+ setExecution(undefined);
+ setReplacementResult(undefined);
+ setSelectedExtraction(undefined);
+ setSelectedCaptureRow(undefined);
+ setRequestedSubjectRange(undefined);
+ setSelectedReplacementContribution(undefined);
+ setRequestedOutputRange(undefined);
+ }, []);
+
+ const invalidateExecution = (preserveCurrentResult = false) => {
+ executionRevision.current += 1;
+ engineSupervisor.current?.cancel();
+ clearExecutionResults();
+ if (!preserveCurrentResult) setCurrentResultDraft(undefined);
+ setRunState({
+ status: "idle",
+ message: "Inputs changed; previous results were cleared.",
+ });
+ };
+
+ const invalidatePatternSyntax = () => {
+ syntaxRevision.current += 1;
+ replacementSyntaxRevision.current += 1;
+ patternSyntaxSupervisor.current?.cancel();
+ replacementSyntaxSupervisor.current?.cancel();
+ setSyntaxSnapshot(undefined);
+ setReplacementSyntax(undefined);
+ setSelectedSyntax(undefined);
+ setSelectedReplacementToken(undefined);
+ setSelectedReplacementContribution(undefined);
+ setRequestedOutputRange(undefined);
+ };
+
+ const changePattern = (value: string) => {
+ invalidateExecution();
+ invalidatePatternSyntax();
+ setRequestedPatternRange(undefined);
+ setPattern(value);
+ };
+
+ const changeSubject = (value: string) => {
+ invalidateExecution();
+ setSubject(value);
+ };
+
+ const changeReplacement = (value: string) => {
+ invalidateExecution();
+ replacementSyntaxRevision.current += 1;
+ replacementSyntaxSupervisor.current?.cancel();
+ setReplacementSyntax(undefined);
+ setSelectedReplacementToken(undefined);
+ setSelectedReplacementContribution(undefined);
+ setRequestedOutputRange(undefined);
+ setReplacement(value);
+ };
+
+ useEffect(
+ () => () => {
+ if (liveExecutionTimer.current !== undefined) {
+ window.clearTimeout(liveExecutionTimer.current);
+ liveExecutionTimer.current = undefined;
+ }
+ patternSyntaxSupervisor.current?.dispose();
+ replacementSyntaxSupervisor.current?.dispose();
+ engineSupervisor.current?.dispose();
+ testSyntaxSupervisor.current?.dispose();
+ testEngineSupervisor.current?.dispose();
+ patternSyntaxSupervisor.current = null;
+ replacementSyntaxSupervisor.current = null;
+ engineSupervisor.current = null;
+ testSyntaxSupervisor.current = null;
+ testEngineSupervisor.current = null;
+ },
+ [],
+ );
+
+ const syntax = isPatternSyntaxForInput(syntaxSnapshot, pattern, flags)
+ ? syntaxSnapshot.result
+ : undefined;
+
+ useEffect(() => {
+ const currentRevision = syntaxRevision.current;
+ const timer = window.setTimeout(() => {
+ if (pattern.length > DEFAULT_REGEX_LIMITS.patternHardLengthUtf16) {
+ if (syntaxRevision.current !== currentRevision) return;
+ setSyntaxSnapshot(undefined);
+ setRunState({
+ status: "error",
+ message: `Pattern exceeds the ${DEFAULT_REGEX_LIMITS.patternHardLengthUtf16.toLocaleString()} UTF-16 unit limit.`,
+ });
+ return;
+ }
+ void patternSyntaxEngine()
+ .parsePattern({
+ flavour: "ecmascript",
+ flavourVersion: "2025",
+ pattern,
+ flags,
+ options: {},
+ })
+ .then((result) => {
+ if (syntaxRevision.current !== currentRevision) return;
+ setSyntaxSnapshot({
+ revision: currentRevision,
+ pattern,
+ flags,
+ result,
+ });
+ setSelectedSyntax((selected) =>
+ selected
+ ? findSmallestNode(result.root, selected.range)
+ : undefined,
+ );
+ if (!result.accepted) {
+ clearExecutionResults();
+ setRunState({
+ status: "error",
+ message:
+ "Execution is paused until both syntax provider and engine accept the pattern.",
+ });
+ } else if (
+ result.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
+ ) {
+ clearExecutionResults();
+ setRunState({
+ status: "error",
+ message: captureGroupLimitMessage(result.captures.length),
+ });
+ }
+ })
+ .catch((error: unknown) => {
+ if (syntaxRevision.current !== currentRevision) return;
+ if (
+ error instanceof WorkerRequestError &&
+ error.kind === "cancelled"
+ ) {
+ return;
+ }
+ clearExecutionResults();
+ setRunState({
+ status: "error",
+ message: error instanceof Error ? error.message : String(error),
+ });
+ });
+ }, DEFAULT_REGEX_LIMITS.liveParseDebounceMs);
+ return () => window.clearTimeout(timer);
+ }, [clearExecutionResults, flags, pattern]);
+
+ useEffect(() => {
+ if (!syntax) return;
+ const currentRevision = ++replacementSyntaxRevision.current;
+ void replacementSyntaxEngine()
+ .parseReplacement({
+ flavour: "ecmascript",
+ replacement,
+ captureMetadata: syntax.captures,
+ })
+ .then((result) => {
+ if (replacementSyntaxRevision.current !== currentRevision) return;
+ setReplacementSyntax(result);
+ })
+ .catch((error: unknown) => {
+ if (replacementSyntaxRevision.current !== currentRevision) return;
+ if (
+ !(error instanceof WorkerRequestError) ||
+ error.kind !== "cancelled"
+ ) {
+ console.warn("Replacement parsing failed", error);
+ }
+ });
+ }, [replacement, syntax]);
+
+ const subjectBytes = useMemo(() => utf8ByteLength(subject), [subject]);
+
+ const makeRequest = useCallback(
+ (): RegexExecutionRequest => ({
+ flavour: "ecmascript",
+ pattern,
+ flags,
+ subject,
+ captureMetadata: syntax?.captures ?? [],
+ scanAll,
+ maximumMatches: DEFAULT_REGEX_LIMITS.maximumMatches,
+ maximumCaptureRows: DEFAULT_REGEX_LIMITS.maximumCaptureRows,
+ }),
+ [flags, pattern, scanAll, subject, syntax?.captures],
+ );
+
+ const run = useCallback(
+ async (kind: "live" | "manual") => {
+ if (kind === "manual" && liveExecutionTimer.current !== undefined) {
+ window.clearTimeout(liveExecutionTimer.current);
+ liveExecutionTimer.current = undefined;
+ }
+ clearExecutionResults();
+ setCurrentResultDraft(undefined);
+ if (
+ !syntax?.accepted ||
+ !isPatternSyntaxCurrent(
+ syntaxSnapshot,
+ syntaxRevision.current,
+ pattern,
+ flags,
+ )
+ ) {
+ setRunState({
+ status: "error",
+ message:
+ "Execution is paused until both syntax provider and engine accept the pattern.",
+ });
+ return;
+ }
+ if (mode === "replace" && replacementSyntax?.accepted !== true) {
+ setRunState({
+ status: "error",
+ message:
+ "Execution is paused until the replacement template is parsed and within its input limit.",
+ });
+ return;
+ }
+ if (syntax.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups) {
+ setRunState({
+ status: "error",
+ message: captureGroupLimitMessage(syntax.captures.length),
+ });
+ return;
+ }
+ if (subjectBytes > DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes) {
+ setRunState({
+ status: "error",
+ message: `Subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} byte limit.`,
+ });
+ return;
+ }
+ const requestRevision = ++executionRevision.current;
+ const limit =
+ kind === "live"
+ ? DEFAULT_REGEX_LIMITS.liveExecutionTimeoutMs
+ : timeoutMs;
+ setRunState({
+ status: "running",
+ message: `Running in a worker with a ${limit} ms limit…`,
+ });
+ try {
+ let elapsedMs = 0;
+ let resultDraftNotice: string | undefined;
+ if (mode === "replace") {
+ const result = await executionEngine().replace(
+ {
+ ...makeRequest(),
+ replacement,
+ maximumOutputBytes:
+ DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
+ },
+ limit,
+ );
+ if (executionRevision.current !== requestRevision) return;
+ setExecution(result.execution);
+ if (!result.execution.accepted) {
+ setReplacementResult(undefined);
+ setRunState({
+ status: "error",
+ message: engineRejectionMessage(result.execution),
+ });
+ return;
+ }
+ setReplacementResult(result);
+ const commonDraft = {
+ name: "Current replacement result",
+ pattern,
+ flags,
+ scanAll,
+ subject,
+ } as const;
+ const decision = chooseReplacementResultDraft({
+ output: result.output,
+ resultTruncated: result.truncated,
+ matchCount: result.execution.matches.length,
+ canStore: (expectation) => {
+ if (tests.length >= MAXIMUM_REGEX_TESTS) return false;
+ const candidate: RegexTestDraft = {
+ ...commonDraft,
+ ...(expectation.kind === "replacement" ? { replacement } : {}),
+ expectation,
+ };
+ try {
+ assertRegexTestSuiteWithinLimit([
+ ...tests,
+ testCaseFromDraft(
+ "current-result-size-check",
+ true,
+ candidate,
+ ),
+ ]);
+ return true;
+ } catch {
+ return false;
+ }
+ },
+ });
+ const resultDraft: RegexTestDraft | undefined = decision.expectation
+ ? {
+ ...commonDraft,
+ name:
+ decision.expectation.kind === "replacement"
+ ? "Current replacement result"
+ : "Current match result",
+ ...(decision.expectation.kind === "replacement"
+ ? { replacement }
+ : {}),
+ expectation: decision.expectation,
+ }
+ : undefined;
+ resultDraftNotice = decision.notice;
+ setCurrentResultDraft(resultDraft);
+ elapsedMs = result.execution.elapsedMs;
+ } else {
+ const result = await executionEngine().execute(makeRequest(), limit);
+ if (executionRevision.current !== requestRevision) return;
+ setExecution(result);
+ if (!result.accepted) {
+ setRunState({
+ status: "error",
+ message: engineRejectionMessage(result),
+ });
+ return;
+ }
+ setCurrentResultDraft(
+ !result.truncated
+ ? {
+ name: "Current match result",
+ pattern,
+ flags,
+ scanAll,
+ subject,
+ expectation: {
+ kind: "match-count",
+ count: result.matches.length,
+ },
+ }
+ : result.matches.length > 0
+ ? {
+ name: "Current match result",
+ pattern,
+ flags,
+ scanAll,
+ subject,
+ expectation: { kind: "should-match" },
+ }
+ : undefined,
+ );
+ elapsedMs = result.elapsedMs;
+ }
+ setRunState({
+ status: "ready",
+ message: `Completed locally in ${elapsedMs.toFixed(2)} ms.${
+ resultDraftNotice ? ` ${resultDraftNotice}` : ""
+ }`,
+ });
+ } catch (error) {
+ if (executionRevision.current !== requestRevision) return;
+ clearExecutionResults();
+ const timedOut =
+ error instanceof WorkerRequestError && error.kind === "timeout";
+ setRunState({
+ status: timedOut ? "timeout" : "error",
+ message: timedOut
+ ? `${error.message}. The worker was terminated and will be recreated for the next run.`
+ : error instanceof Error
+ ? error.message
+ : String(error),
+ });
+ }
+ },
+ [
+ clearExecutionResults,
+ makeRequest,
+ mode,
+ replacement,
+ replacementSyntax?.accepted,
+ scanAll,
+ subjectBytes,
+ subject,
+ syntax?.accepted,
+ syntax?.captures.length,
+ syntaxSnapshot,
+ flags,
+ pattern,
+ tests,
+ timeoutMs,
+ ],
+ );
+
+ const liveWithinLimits =
+ pattern.length <= DEFAULT_REGEX_LIMITS.patternSoftLengthUtf16 &&
+ subjectBytes <= DEFAULT_REGEX_LIMITS.interactiveSubjectSoftBytes;
+
+ useEffect(() => {
+ if (
+ !live ||
+ !liveWithinLimits ||
+ !syntax?.accepted ||
+ mode === "tests" ||
+ (mode === "replace" && replacementSyntax?.accepted !== true)
+ ) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ if (liveExecutionTimer.current === timer) {
+ liveExecutionTimer.current = undefined;
+ }
+ void run("live");
+ }, DEFAULT_REGEX_LIMITS.liveExecutionDebounceMs);
+ liveExecutionTimer.current = timer;
+ return () => {
+ window.clearTimeout(timer);
+ if (liveExecutionTimer.current === timer) {
+ liveExecutionTimer.current = undefined;
+ }
+ };
+ }, [
+ live,
+ liveWithinLimits,
+ mode,
+ replacement,
+ replacementSyntax?.accepted,
+ run,
+ syntax?.accepted,
+ ]);
+
+ const extraction = useMemo(
+ () => buildExtractionTree(execution?.matches ?? [], syntax?.captures ?? []),
+ [execution?.matches, syntax?.captures],
+ );
+ const captureRows = useMemo(
+ () => buildCaptureRows(execution?.matches ?? [], subject),
+ [execution?.matches, subject],
+ );
+ const repeatedCaptures = useMemo(
+ () =>
+ new Set(
+ syntax?.captures
+ .filter((capture) => capture.repeated)
+ .map((capture) => capture.number) ?? [],
+ ),
+ [syntax?.captures],
+ );
+ const selectedSubjectRange = useMemo(
+ () =>
+ selectedCaptureRow?.start === undefined
+ ? selectedExtraction?.range
+ : {
+ startUtf16: selectedCaptureRow.start,
+ endUtf16: selectedCaptureRow.end ?? selectedCaptureRow.start,
+ },
+ [selectedCaptureRow, selectedExtraction?.range],
+ );
+ const subjectMarkSummary = useMemo(
+ () => subjectMarks(execution, selectedSubjectRange),
+ [execution, selectedSubjectRange],
+ );
+ const patternMarkSummary = useMemo(
+ () => syntaxMarks(syntax, selectedSyntax),
+ [selectedSyntax, syntax],
+ );
+ const replacementPreview = useMemo(
+ () =>
+ replacementSyntax?.accepted === true && replacementResult
+ ? buildReplacementPreview(replacementSyntax, replacementResult, subject)
+ : undefined,
+ [replacementResult, replacementSyntax, subject],
+ );
+ const diagnostics = [
+ ...(syntax?.diagnostics ?? []),
+ ...(replacementSyntax?.diagnostics ?? []),
+ ...(execution?.diagnostics ?? []),
+ ];
+ const currentProject: RegexProjectV1 = {
+ schemaVersion: 1,
+ id: projectIdentity.id,
+ name: "Regex workbench",
+ createdAt: projectIdentity.createdAt,
+ updatedAt: new Date().toISOString(),
+ flavour: "ecmascript",
+ flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
+ pattern,
+ flags,
+ options: {},
+ replacement,
+ mode,
+ testText: { included: true, value: subject },
+ listTemplate: parseListTemplate(listTemplate),
+ tests,
+ ui: { live, scanAll },
+ };
+
+ const selectExtraction = (
+ node: ExtractionNode,
+ requestEditorSelection = true,
+ ) => {
+ setSelectedExtraction(node);
+ const matchNumber = /^match-(\d+)(?:-|$)/u.exec(node.id)?.[1];
+ const captureRow =
+ node.groupNumber === undefined
+ ? undefined
+ : captureRows.find(
+ (row) =>
+ row.groupNumber === node.groupNumber &&
+ (matchNumber === undefined ||
+ row.matchNumber === Number(matchNumber)) &&
+ (node.range === undefined ||
+ (row.start === node.range.startUtf16 &&
+ row.end === node.range.endUtf16)),
+ );
+ setSelectedCaptureRow(captureRow);
+ setRequestedSubjectRange(
+ requestEditorSelection && node.range
+ ? {
+ startUtf16: node.range.startUtf16,
+ endUtf16: node.range.endUtf16,
+ }
+ : undefined,
+ );
+ if (node.groupNumber !== undefined) {
+ const definition = syntax?.captures.find(
+ (capture) => capture.number === node.groupNumber,
+ );
+ if (definition && syntax) {
+ const selected = findSmallestNode(syntax.root, definition.range);
+ setSelectedSyntax(selected);
+ setRequestedPatternRange(
+ selected
+ ? {
+ startUtf16: selected.range.startUtf16,
+ endUtf16: selected.range.endUtf16,
+ }
+ : undefined,
+ );
+ }
+ } else {
+ setSelectedSyntax(syntax?.root);
+ setRequestedPatternRange(syntax?.root.range);
+ }
+ };
+
+ const selectSyntaxNode = (
+ node: NormalizedRegexNode,
+ requestEditorSelection = true,
+ ) => {
+ setSelectedSyntax(node);
+ setRequestedPatternRange(
+ requestEditorSelection
+ ? {
+ startUtf16: node.range.startUtf16,
+ endUtf16: node.range.endUtf16,
+ }
+ : undefined,
+ );
+
+ const enclosingCapture = syntax?.captures
+ .filter(
+ (capture) =>
+ capture.range.startUtf16 <= node.range.startUtf16 &&
+ capture.range.endUtf16 >= node.range.endUtf16,
+ )
+ .sort(
+ (left, right) =>
+ left.range.endUtf16 -
+ left.range.startUtf16 -
+ (right.range.endUtf16 - right.range.startUtf16),
+ )[0];
+ const groupNumber = node.capture?.number ?? enclosingCapture?.number;
+ if (groupNumber === undefined) {
+ setSelectedExtraction(undefined);
+ setSelectedCaptureRow(undefined);
+ setRequestedSubjectRange(undefined);
+ return;
+ }
+
+ const selectedMatchNumber =
+ selectedCaptureRow?.matchNumber ??
+ Number(/^match-(\d+)(?:-|$)/u.exec(selectedExtraction?.id ?? "")?.[1]);
+ const matchingRows = captureRows.filter(
+ (row) => row.groupNumber === groupNumber,
+ );
+ const captureRow =
+ matchingRows.find(
+ (row) =>
+ row.matchNumber === selectedMatchNumber && row.start !== undefined,
+ ) ??
+ matchingRows.find((row) => row.start !== undefined) ??
+ matchingRows.find((row) => row.matchNumber === selectedMatchNumber) ??
+ matchingRows[0];
+ const extractionNode = captureRow
+ ? findExtractionNodeById(
+ extraction,
+ `match-${captureRow.matchNumber}-capture-${groupNumber}`,
+ )
+ : undefined;
+
+ setSelectedCaptureRow(captureRow);
+ setSelectedExtraction(extractionNode);
+ setRequestedSubjectRange(
+ captureRow?.start === undefined
+ ? undefined
+ : {
+ startUtf16: captureRow.start,
+ endUtf16: captureRow.end ?? captureRow.start,
+ },
+ );
+ };
+
+ const selectReplacementContribution = (
+ contribution: ReplacementContributionPreview,
+ ) => {
+ setSelectedReplacementContribution(contribution);
+ setSelectedReplacementToken(contribution.token);
+ setRequestedOutputRange(contribution.outputRange);
+
+ const match = execution?.matches.find(
+ (candidate) => candidate.matchNumber === contribution.matchNumber,
+ );
+ const capture =
+ contribution.token.captureNumber !== undefined
+ ? match?.captures.find(
+ (candidate) =>
+ candidate.groupNumber === contribution.token.captureNumber,
+ )
+ : contribution.token.captureName !== undefined
+ ? (match?.captures.find(
+ (candidate) =>
+ candidate.groupName === contribution.token.captureName &&
+ candidate.status !== "did-not-participate",
+ ) ??
+ match?.captures.find(
+ (candidate) =>
+ candidate.groupName === contribution.token.captureName,
+ ))
+ : undefined;
+ const extractionNode = findExtractionNodeById(
+ extraction,
+ capture
+ ? `match-${contribution.matchNumber}-capture-${capture.groupNumber}`
+ : `match-${contribution.matchNumber}`,
+ );
+ if (extractionNode) {
+ selectExtraction(extractionNode);
+ } else {
+ setSelectedExtraction(undefined);
+ setSelectedCaptureRow(undefined);
+ setRequestedSubjectRange(match?.range);
+ }
+ };
+
+ const runTests = async (selectedIds?: readonly string[]) => {
+ const selected =
+ selectedIds === undefined ? undefined : new Set(selectedIds);
+ const testsToRun = tests.filter(
+ (candidate) =>
+ candidate.enabled &&
+ (selected === undefined || selected.has(candidate.id)),
+ );
+ if (testsToRun.length === 0) return;
+ const runRevision = ++testRunRevision.current;
+ setLive(false);
+ setTestsRunning(true);
+ const next =
+ selected === undefined
+ ? new Map()
+ : new Map(testResults);
+ for (const test of testsToRun) next.delete(test.id);
+ setTestResults(new Map(next));
+ const deadline = performance.now() + MAXIMUM_TEST_SUITE_WALL_TIME_MS;
+ let activeIndex = 0;
+ const stopAtSuiteLimit = (fromIndex: number) => {
+ if (testRunRevision.current !== runRevision) return;
+ testRunRevision.current += 1;
+ testSyntaxSupervisor.current?.cancel();
+ testEngineSupervisor.current?.cancel();
+ for (const remaining of testsToRun.slice(fromIndex)) {
+ next.set(
+ remaining.id,
+ failedTestResult(
+ remaining.id,
+ `Suite stopped at the ${MAXIMUM_TEST_SUITE_WALL_TIME_MS / 1_000} second aggregate wall-time limit.`,
+ ),
+ );
+ }
+ setTestResults(new Map(next));
+ setTestsRunning(false);
+ };
+ const suiteDeadlineTimer = window.setTimeout(() => {
+ stopAtSuiteLimit(activeIndex);
+ }, MAXIMUM_TEST_SUITE_WALL_TIME_MS);
+ try {
+ for (const [index, test] of testsToRun.entries()) {
+ activeIndex = index;
+ if (testRunRevision.current !== runRevision) return;
+ if (performance.now() >= deadline) {
+ stopAtSuiteLimit(index);
+ return;
+ }
+ try {
+ if (
+ utf8ByteLength(test.subject) >
+ DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
+ ) {
+ next.set(
+ test.id,
+ failedTestResult(
+ test.id,
+ `Test subject exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} byte limit.`,
+ ),
+ );
+ setTestResults(new Map(next));
+ continue;
+ }
+ const parsed = await testSyntaxEngine().parsePattern({
+ flavour: test.flavour,
+ pattern: test.pattern,
+ flags: test.flags,
+ options: test.options,
+ });
+ if (testRunRevision.current !== runRevision) return;
+ if (!parsed.accepted) {
+ next.set(
+ test.id,
+ failedTestResult(
+ test.id,
+ parsed.diagnostics[0]?.message ?? "Invalid test pattern.",
+ ),
+ );
+ } else if (
+ parsed.captures.length > DEFAULT_REGEX_LIMITS.maximumCaptureGroups
+ ) {
+ next.set(
+ test.id,
+ failedTestResult(
+ test.id,
+ captureGroupLimitMessage(parsed.captures.length),
+ ),
+ );
+ } else {
+ next.set(
+ test.id,
+ await runRegexTest(
+ test,
+ parsed.captures,
+ testExecutionEngine(),
+ timeoutMs,
+ DEFAULT_REGEX_LIMITS.maximumMatches,
+ DEFAULT_REGEX_LIMITS.maximumCaptureRows,
+ DEFAULT_REGEX_LIMITS.maximumReplacementOutputBytes,
+ MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+ ),
+ );
+ }
+ } catch (error) {
+ if (testRunRevision.current !== runRevision) return;
+ next.set(
+ test.id,
+ failedTestResult(
+ test.id,
+ error instanceof Error ? error.message : String(error),
+ ),
+ );
+ }
+ if (testRunRevision.current !== runRevision) return;
+ setTestResults(new Map(next));
+ }
+ } finally {
+ window.clearTimeout(suiteDeadlineTimer);
+ if (testRunRevision.current === runRevision) setTestsRunning(false);
+ }
+ };
+
+ const cancelTestRun = () => {
+ testRunRevision.current += 1;
+ testSyntaxSupervisor.current?.cancel();
+ testEngineSupervisor.current?.cancel();
+ setTestsRunning(false);
+ };
+
+ const discardTestResult = (id: string) => {
+ setTestResults((current) => {
+ if (!current.has(id)) return current;
+ const next = new Map(current);
+ next.delete(id);
+ return next;
+ });
+ };
+
+ const addTest = (draft: RegexTestDraft) => {
+ if (tests.length >= MAXIMUM_REGEX_TESTS) {
+ throw new Error(
+ `A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
+ );
+ }
+ const next = [...tests, testCaseFromDraft(projectId(), true, draft)];
+ assertRegexTestSuiteWithinLimit(next);
+ setTests(next);
+ };
+
+ const updateTest = (id: string, draft: RegexTestDraft) => {
+ const next = tests.map((test) =>
+ test.id === id ? testCaseFromDraft(test.id, test.enabled, draft) : test,
+ );
+ assertRegexTestSuiteWithinLimit(next);
+ setTests(next);
+ discardTestResult(id);
+ };
+
+ const cloneTest = (id: string) => {
+ if (tests.length >= MAXIMUM_REGEX_TESTS) {
+ throw new Error(
+ `A suite is limited to ${MAXIMUM_REGEX_TESTS.toLocaleString()} tests.`,
+ );
+ }
+ const source = tests.find((test) => test.id === id);
+ if (!source) return;
+ const suffix = " copy";
+ const cloneName = `${source.name.slice(
+ 0,
+ MAXIMUM_REGEX_TEST_NAME_UTF16 - suffix.length,
+ )}${suffix}`;
+ const next = [
+ ...tests,
+ {
+ ...source,
+ id: projectId(),
+ name: cloneName,
+ },
+ ];
+ assertRegexTestSuiteWithinLimit(next);
+ setTests(next);
+ };
+
+ const importTests = (imported: readonly RegexTestCase[]) => {
+ if (tests.length + imported.length > MAXIMUM_REGEX_TESTS) {
+ throw new Error(
+ `Import would exceed the ${MAXIMUM_REGEX_TESTS.toLocaleString()} test limit.`,
+ );
+ }
+ const ids = new Set(tests.map((test) => test.id));
+ const appended = imported.map((test) => {
+ let id = test.id;
+ while (ids.has(id)) id = projectId();
+ ids.add(id);
+ return id === test.id ? test : { ...test, id };
+ });
+ const next = [...tests, ...appended];
+ assertRegexTestSuiteWithinLimit(next);
+ setTests(next);
+ };
+
+ const importProject = (project: RegexProjectV1) => {
+ invalidatePatternSyntax();
+ executionRevision.current += 1;
+ engineSupervisor.current?.cancel();
+ cancelTestRun();
+ setPattern(project.pattern);
+ setProjectIdentity({ id: project.id, createdAt: project.createdAt });
+ setFlags(project.flags);
+ setSubject(
+ project.testText?.included ? (project.testText.value ?? "") : "",
+ );
+ setReplacement(project.replacement ?? "");
+ setListTemplate(project.listTemplate?.source ?? "$0");
+ setMode(project.mode);
+ setTests(project.tests);
+ setCurrentResultDraft(undefined);
+ setTestResults(new Map());
+ setLive(false);
+ setScanAll(project.ui?.scanAll ?? false);
+ clearExecutionResults();
+ setSelectedSyntax(undefined);
+ setRequestedPatternRange(undefined);
+ setSelectedReplacementToken(undefined);
+ setRunState({
+ status: "idle",
+ message: "Project imported and validated. Review it, then run manually.",
+ });
+ };
+
+ return (
+
+
+ {!liveWithinLimits && live ? (
+
+ Live execution is paused above the soft input limit. Syntax parsing
+ continues; use Run manually.
+
+ ) : null}
+ {scanAll && !flags.some((flag) => flag === "g" || flag === "y") ? (
+
+ Scan all is explicit: the worker adds an internal g only
+ for iteration. The pattern’s saved user flags remain unchanged.
+
+ ) : null}
+
+
+ {(
+ [
+ ["match", "Match"],
+ ["replace", "Replace"],
+ ["list", "List"],
+ ["tests", "Unit tests"],
+ ] as const
+ ).map(([value, label]) => (
+ {
+ if (mode === value) return;
+ invalidateExecution(value === "tests");
+ setMode(value);
+ }}
+ >
+ {label}
+
+ ))}
+
+
+ setShowReference((value) => !value)}
+ aria-expanded={showReference}
+ >
+ Quick reference
+
+ setShowCapabilities((value) => !value)}
+ aria-expanded={showCapabilities}
+ >
+ Capabilities
+
+ setShowProject((value) => !value)}
+ aria-expanded={showProject}
+ >
+ Project
+
+
+
setShowProject(false)}
+ onImport={importProject}
+ onSaveLocal={(includeText, includeUnitTestSubjects) =>
+ saveProjectLocally(
+ currentProject,
+ includeText,
+ includeUnitTestSubjects,
+ )
+ }
+ onLoadLocal={async () => {
+ const loaded = await loadMostRecentProject();
+ if (!loaded)
+ throw new Error("No local save exists for this project.");
+ importProject(loaded);
+ }}
+ />
+
+
+ {runState.status}
+ {runState.message}
+ {execution?.accepted ? (
+
+ {execution.matches.length.toLocaleString()} matches ·{" "}
+ {execution.elapsedMs.toFixed(2)} ms · native offsets{" "}
+ {execution.engine.offsetUnit}
+
+ ) : null}
+
+
+
+
+ {patternMarkSummary.truncated ? (
+
+ Editor decorations limited. Showing the first{" "}
+ {patternMarkSummary.renderedTokenMarks.toLocaleString()} of{" "}
+ {patternMarkSummary.totalTokenMarks.toLocaleString()} syntax
+ decorations. The complete normalized syntax model remains
+ available.
+
+ ) : null}
+ {
+ setRequestedPatternRange(undefined);
+ const node = syntax?.root
+ ? findSmallestNode(syntax.root, range)
+ : undefined;
+ if (node) {
+ selectSyntaxNode(node, false);
+ } else {
+ setSelectedSyntax(undefined);
+ setSelectedExtraction(undefined);
+ setSelectedCaptureRow(undefined);
+ setRequestedSubjectRange(undefined);
+ }
+ }}
+ label="Regular expression pattern"
+ className="pattern-editor"
+ maximumLengthUtf16={DEFAULT_REGEX_LIMITS.patternHardLengthUtf16}
+ marks={patternMarkSummary.marks}
+ selectedRange={requestedPatternRange}
+ />
+
+
selectSyntaxNode(node)}
+ />
+
+ {mode !== "tests" ? (
+
+
+
+ {subjectMarkSummary.truncated ? (
+
+ Editor highlights limited. Showing the first{" "}
+ {subjectMarkSummary.renderedResultMarks.toLocaleString()} of{" "}
+ {subjectMarkSummary.totalResultMarks.toLocaleString()} returned
+ match and capture highlights. All returned matches remain
+ available in the result views and exports.
+
+ ) : null}
+ {
+ setRequestedSubjectRange(undefined);
+ const node = findSmallestExtractionNode(extraction, range);
+ if (node) {
+ selectExtraction(node, false);
+ } else {
+ setSelectedExtraction(undefined);
+ setSelectedCaptureRow(undefined);
+ }
+ }}
+ onCursorPositionChange={setSubjectCursor}
+ label="Test text"
+ className="subject-editor"
+ maximumLengthUtf16={
+ DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
+ }
+ maximumUtf8Bytes={
+ DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
+ }
+ lineNumberGutter
+ showWhitespace={showSubjectWhitespace}
+ marks={subjectMarkSummary.marks}
+ selectedRange={requestedSubjectRange}
+ />
+
+
selectExtraction(node)}
+ repeatedCaptureNumbers={repeatedCaptures}
+ />
+
+ ) : null}
+ {mode === "match" ? (
+ {
+ const extractionNode = findExtractionNodeById(
+ extraction,
+ `match-${row.matchNumber}-capture-${row.groupNumber}`,
+ );
+ if (extractionNode) {
+ selectExtraction(extractionNode);
+ } else {
+ setSelectedCaptureRow(row);
+ setSelectedExtraction(undefined);
+ setRequestedSubjectRange(
+ row.start === undefined
+ ? undefined
+ : {
+ startUtf16: row.start,
+ endUtf16: row.end ?? row.start,
+ },
+ );
+ }
+ const definition = syntax?.captures.find(
+ (capture) => capture.number === row.groupNumber,
+ );
+ if (definition && syntax) {
+ const selected = findSmallestNode(syntax.root, definition.range);
+ setSelectedSyntax(selected);
+ setRequestedPatternRange(
+ selected
+ ? {
+ startUtf16: selected.range.startUtf16,
+ endUtf16: selected.range.endUtf16,
+ }
+ : undefined,
+ );
+ }
+ }}
+ />
+ ) : null}
+ {mode === "replace" ? (
+ {
+ setSelectedReplacementToken(token);
+ setSelectedReplacementContribution(undefined);
+ setRequestedOutputRange(undefined);
+ }}
+ onSelectContribution={selectReplacementContribution}
+ />
+ ) : null}
+ {mode === "list" ? (
+
+ ) : null}
+ {mode === "tests" ? (
+ {
+ setTests((current) => current.filter((test) => test.id !== id));
+ discardTestResult(id);
+ }}
+ onToggle={(id) =>
+ setTests((current) =>
+ current.map((test) =>
+ test.id === id ? { ...test, enabled: !test.enabled } : test,
+ ),
+ )
+ }
+ onRunAll={() => void runTests()}
+ onRunSelected={(ids) => void runTests(ids)}
+ onCancel={cancelTestRun}
+ />
+ ) : null}
+ {
+ const diagnosticRange = diagnostic.range;
+ if (diagnostic.id.startsWith("replacement-") && diagnosticRange) {
+ const token = replacementSyntax?.tokens.find(
+ (candidate) =>
+ candidate.range.startUtf16 <= diagnosticRange.startUtf16 &&
+ candidate.range.endUtf16 >= diagnosticRange.endUtf16,
+ );
+ setSelectedReplacementToken(token);
+ setSelectedReplacementContribution(undefined);
+ setRequestedOutputRange(undefined);
+ return;
+ }
+ if (diagnosticRange && syntax?.root) {
+ const selected = findSmallestNode(syntax.root, diagnosticRange);
+ setSelectedSyntax(selected);
+ setRequestedPatternRange(
+ selected
+ ? {
+ startUtf16: selected.range.startUtf16,
+ endUtf16: selected.range.endUtf16,
+ }
+ : undefined,
+ );
+ }
+ }}
+ />
+ {showReference ? : null}
+ {showCapabilities ? (
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/tree-navigation.ts b/src/components/tree-navigation.ts
new file mode 100644
index 0000000..2180745
--- /dev/null
+++ b/src/components/tree-navigation.ts
@@ -0,0 +1,65 @@
+import type { KeyboardEvent } from "react";
+
+function activate(items: readonly HTMLButtonElement[], index: number): void {
+ const item = items[index];
+ if (!item) return;
+ item.focus();
+ item.click();
+}
+
+export function handleTreeNavigation(
+ event: KeyboardEvent,
+): void {
+ const tree = event.currentTarget.closest('[role="tree"]');
+ const items = tree
+ ? Array.from(
+ tree.querySelectorAll('button[role="treeitem"]'),
+ )
+ : [];
+ const index = items.indexOf(event.currentTarget);
+ if (index < 0) return;
+
+ let target = index;
+ switch (event.key) {
+ case "ArrowDown":
+ target = Math.min(items.length - 1, index + 1);
+ break;
+ case "ArrowUp":
+ target = Math.max(0, index - 1);
+ break;
+ case "Home":
+ target = 0;
+ break;
+ case "End":
+ target = items.length - 1;
+ break;
+ case "ArrowRight": {
+ const currentLevel = Number(
+ event.currentTarget.getAttribute("aria-level"),
+ );
+ const nextLevel = Number(items[index + 1]?.getAttribute("aria-level"));
+ if (nextLevel === currentLevel + 1) target = index + 1;
+ break;
+ }
+ case "ArrowLeft": {
+ const currentLevel = Number(
+ event.currentTarget.getAttribute("aria-level"),
+ );
+ for (let candidate = index - 1; candidate >= 0; candidate -= 1) {
+ if (
+ Number(items[candidate]?.getAttribute("aria-level")) ===
+ currentLevel - 1
+ ) {
+ target = candidate;
+ break;
+ }
+ }
+ break;
+ }
+ default:
+ return;
+ }
+
+ event.preventDefault();
+ activate(items, target);
+}
diff --git a/src/editors/CodeEditor.tsx b/src/editors/CodeEditor.tsx
new file mode 100644
index 0000000..ac569b2
--- /dev/null
+++ b/src/editors/CodeEditor.tsx
@@ -0,0 +1,320 @@
+import { useEffect, useRef, useState } from "react";
+import {
+ Compartment,
+ EditorSelection,
+ EditorState,
+ StateEffect,
+ StateField,
+} from "@codemirror/state";
+import {
+ Decoration,
+ EditorView,
+ WidgetType,
+ highlightWhitespace,
+ keymap,
+ lineNumbers,
+ type DecorationSet,
+} from "@codemirror/view";
+import {
+ defaultKeymap,
+ history,
+ historyKeymap,
+ indentWithTab,
+} from "@codemirror/commands";
+import { searchKeymap } from "@codemirror/search";
+import type { EditorMark } from "./editor.types";
+import type { SourceRange } from "../regex/model/syntax";
+
+const setMarks = StateEffect.define();
+
+function decorationSet(
+ marks: readonly EditorMark[],
+ documentLength: number,
+): DecorationSet {
+ return Decoration.set(
+ marks.flatMap((mark) => {
+ const from = Math.max(0, Math.min(documentLength, mark.range.startUtf16));
+ const to = Math.max(from, Math.min(documentLength, mark.range.endUtf16));
+ if (mark.zeroWidth || from === to) {
+ return [
+ Decoration.widget({
+ widget: new ZeroWidthWidget(mark.className, mark.label),
+ side: 1,
+ }).range(from),
+ ];
+ }
+ return [
+ Decoration.mark({
+ class: mark.className,
+ attributes: mark.label
+ ? { "aria-label": mark.label, title: mark.label }
+ : undefined,
+ }).range(from, to),
+ ];
+ }),
+ true,
+ );
+}
+
+class ZeroWidthWidget extends WidgetType {
+ private readonly className: string;
+ private readonly label: string | undefined;
+
+ constructor(className: string, label?: string) {
+ super();
+ this.className = className;
+ this.label = label;
+ }
+
+ toDOM(): HTMLElement {
+ const marker = document.createElement("span");
+ marker.className = `cm-zero-width ${this.className}`;
+ marker.textContent = "▏";
+ marker.setAttribute("aria-label", this.label ?? "Zero-width match");
+ return marker;
+ }
+}
+
+const marksField = StateField.define({
+ create: () => Decoration.none,
+ update: (marks, transaction) => {
+ let next = marks.map(transaction.changes);
+ for (const effect of transaction.effects) {
+ if (effect.is(setMarks)) {
+ next = decorationSet(effect.value, transaction.newDoc.length);
+ }
+ }
+ return next;
+ },
+ provide: (field) => EditorView.decorations.from(field),
+});
+
+export interface CodeEditorProps {
+ readonly value: string;
+ readonly onChange?: (value: string) => void;
+ readonly onSelectionChange?: (range: SourceRange) => void;
+ readonly onCursorPositionChange?: (position: {
+ readonly offsetUtf16: number;
+ readonly line: number;
+ readonly column: number;
+ }) => void;
+ readonly label: string;
+ readonly className?: string;
+ readonly marks?: readonly EditorMark[];
+ readonly selectedRange?: SourceRange;
+ readonly lineNumberGutter?: boolean;
+ readonly readOnly?: boolean;
+ readonly placeholder?: string;
+ readonly maximumLengthUtf16?: number;
+ readonly maximumUtf8Bytes?: number;
+ readonly showWhitespace?: boolean;
+}
+
+export function CodeEditor({
+ value,
+ onChange,
+ onSelectionChange,
+ onCursorPositionChange,
+ label,
+ className,
+ marks = [],
+ selectedRange,
+ lineNumberGutter = false,
+ readOnly = false,
+ placeholder,
+ maximumLengthUtf16,
+ maximumUtf8Bytes,
+ showWhitespace = false,
+}: CodeEditorProps) {
+ const host = useRef(null);
+ const view = useRef(null);
+ const onChangeRef = useRef(onChange);
+ const onSelectionRef = useRef(onSelectionChange);
+ const onCursorPositionRef = useRef(onCursorPositionChange);
+ const applyingExternalUpdate = useRef(false);
+ const whitespaceConfiguration = useRef(new Compartment());
+ const [limitMessage, setLimitMessage] = useState();
+
+ useEffect(() => {
+ onChangeRef.current = onChange;
+ }, [onChange]);
+
+ useEffect(() => {
+ onSelectionRef.current = onSelectionChange;
+ }, [onSelectionChange]);
+
+ useEffect(() => {
+ onCursorPositionRef.current = onCursorPositionChange;
+ }, [onCursorPositionChange]);
+
+ useEffect(() => {
+ if (!host.current) return;
+ const encoder = new TextEncoder();
+ let documentUtf8Bytes =
+ maximumUtf8Bytes === undefined
+ ? undefined
+ : encoder.encode(value).byteLength;
+ const editor = new EditorView({
+ parent: host.current,
+ state: EditorState.create({
+ doc: value,
+ extensions: [
+ ...(lineNumberGutter ? [lineNumbers()] : []),
+ history(),
+ keymap.of([
+ indentWithTab,
+ ...defaultKeymap,
+ ...historyKeymap,
+ ...searchKeymap,
+ ]),
+ marksField,
+ whitespaceConfiguration.current.of(
+ showWhitespace ? highlightWhitespace() : [],
+ ),
+ EditorState.transactionFilter.of((transaction) => {
+ if (!transaction.docChanged) return transaction;
+ if (
+ maximumLengthUtf16 !== undefined &&
+ transaction.newDoc.length > maximumLengthUtf16
+ ) {
+ setLimitMessage(
+ `${label} is limited to ${maximumLengthUtf16.toLocaleString()} UTF-16 units.`,
+ );
+ return [];
+ }
+ if (
+ maximumUtf8Bytes !== undefined &&
+ documentUtf8Bytes !== undefined
+ ) {
+ let nextBytes = documentUtf8Bytes;
+ transaction.changes.iterChanges(
+ (fromA, toA, _fromB, _toB, inserted) => {
+ nextBytes -= encoder.encode(
+ transaction.startState.doc.sliceString(fromA, toA),
+ ).byteLength;
+ nextBytes += encoder.encode(inserted.toString()).byteLength;
+ },
+ );
+ if (nextBytes > maximumUtf8Bytes) {
+ setLimitMessage(
+ `${label} is limited to ${maximumUtf8Bytes.toLocaleString()} UTF-8 bytes.`,
+ );
+ return [];
+ }
+ documentUtf8Bytes = nextBytes;
+ }
+ return transaction;
+ }),
+ EditorState.readOnly.of(readOnly),
+ EditorView.lineWrapping,
+ EditorView.contentAttributes.of({
+ "aria-label": label,
+ "aria-multiline": "true",
+ ...(placeholder ? { "data-placeholder": placeholder } : {}),
+ }),
+ EditorView.updateListener.of((update) => {
+ if (update.docChanged && !applyingExternalUpdate.current) {
+ setLimitMessage(undefined);
+ onChangeRef.current?.(update.state.doc.toString());
+ }
+ if (
+ update.selectionSet &&
+ !update.docChanged &&
+ !applyingExternalUpdate.current
+ ) {
+ const selection = update.state.selection.main;
+ onSelectionRef.current?.({
+ startUtf16: selection.from,
+ endUtf16: selection.to,
+ });
+ }
+ if (update.selectionSet || update.docChanged) {
+ const offsetUtf16 = update.state.selection.main.head;
+ const line = update.state.doc.lineAt(offsetUtf16);
+ onCursorPositionRef.current?.({
+ offsetUtf16,
+ line: line.number,
+ column: offsetUtf16 - line.from + 1,
+ });
+ }
+ }),
+ ],
+ }),
+ });
+ view.current = editor;
+ return () => {
+ editor.destroy();
+ view.current = null;
+ };
+ // Editor construction is intentionally one-time. Mutable callbacks and
+ // document/decoration synchronization are handled through refs/effects.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ const editor = view.current;
+ if (!editor || editor.state.doc.toString() === value) return;
+ applyingExternalUpdate.current = true;
+ try {
+ editor.dispatch({
+ changes: {
+ from: 0,
+ to: editor.state.doc.length,
+ insert: value,
+ },
+ });
+ } finally {
+ applyingExternalUpdate.current = false;
+ }
+ }, [value]);
+
+ useEffect(() => {
+ view.current?.dispatch({ effects: setMarks.of(marks) });
+ }, [marks]);
+
+ useEffect(() => {
+ view.current?.dispatch({
+ effects: whitespaceConfiguration.current.reconfigure(
+ showWhitespace ? highlightWhitespace() : [],
+ ),
+ });
+ }, [showWhitespace]);
+
+ useEffect(() => {
+ const editor = view.current;
+ if (!editor || !selectedRange) return;
+ const start = Math.max(
+ 0,
+ Math.min(editor.state.doc.length, selectedRange.startUtf16),
+ );
+ const end = Math.max(
+ start,
+ Math.min(editor.state.doc.length, selectedRange.endUtf16),
+ );
+ const current = editor.state.selection.main;
+ if (current.from === start && current.to === end) return;
+ applyingExternalUpdate.current = true;
+ try {
+ editor.dispatch({
+ selection: EditorSelection.single(start, end),
+ effects: EditorView.scrollIntoView(start, { y: "center" }),
+ });
+ } finally {
+ applyingExternalUpdate.current = false;
+ }
+ }, [selectedRange]);
+
+ return (
+
+
+ {limitMessage ? (
+
+ {limitMessage}
+
+ ) : null}
+
+ );
+}
diff --git a/src/editors/editor.types.ts b/src/editors/editor.types.ts
new file mode 100644
index 0000000..2152498
--- /dev/null
+++ b/src/editors/editor.types.ts
@@ -0,0 +1,9 @@
+import type { SourceRange } from "../regex/model/syntax";
+
+export interface EditorMark {
+ readonly id: string;
+ readonly range: SourceRange;
+ readonly className: string;
+ readonly label?: string;
+ readonly zeroWidth?: boolean;
+}
diff --git a/src/main.tsx b/src/main.tsx
new file mode 100644
index 0000000..1c711a7
--- /dev/null
+++ b/src/main.tsx
@@ -0,0 +1,9 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+
+createRoot(document.getElementById("root")!).render(
+
+
+ ,
+);
diff --git a/src/project/persistence.test.ts b/src/project/persistence.test.ts
new file mode 100644
index 0000000..21b3dc7
--- /dev/null
+++ b/src/project/persistence.test.ts
@@ -0,0 +1,147 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { RegexProjectV1 } from "./project.types";
+import { loadMostRecentProject, saveProjectLocally } from "./persistence";
+
+const project: RegexProjectV1 = {
+ schemaVersion: 1,
+ id: "project-1",
+ name: "Latest",
+ createdAt: "2026-07-24T00:00:00.000Z",
+ updatedAt: "2026-07-24T01:00:00.000Z",
+ flavour: "ecmascript",
+ pattern: "a",
+ flags: ["g"],
+ options: {},
+ replacement: "$&",
+ mode: "match",
+ testText: { included: false },
+ listTemplate: { source: "$0", tokens: [{ kind: "full-match" }] },
+ tests: [],
+ ui: { live: false, scanAll: false },
+};
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+describe("project persistence bounds", () => {
+ it("omits unit-test subjects by default and stores them only after opt-in", async () => {
+ const put = vi.fn();
+ const close = vi.fn();
+ const database = {
+ transaction: vi.fn(() => {
+ const transaction = {
+ objectStore: () => ({ put }),
+ error: null,
+ oncomplete: null as ((event: Event) => void) | null,
+ onerror: null as ((event: Event) => void) | null,
+ };
+ queueMicrotask(() => transaction.oncomplete?.(new Event("complete")));
+ return transaction;
+ }),
+ close,
+ };
+ const open = vi.fn(() => {
+ const request = {
+ result: database,
+ error: null,
+ transaction: null,
+ onupgradeneeded: null as ((event: Event) => void) | null,
+ onsuccess: null as ((event: Event) => void) | null,
+ onerror: null as ((event: Event) => void) | null,
+ };
+ queueMicrotask(() => request.onsuccess?.(new Event("success")));
+ return request as unknown as IDBOpenDBRequest;
+ });
+ vi.stubGlobal("indexedDB", { open } as unknown as IDBFactory);
+ const withSubject: RegexProjectV1 = {
+ ...project,
+ tests: [
+ {
+ id: "sensitive-test",
+ name: "Sensitive",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "secret",
+ flags: [],
+ options: {},
+ subject: "private subject",
+ expectation: { kind: "should-match" },
+ },
+ ],
+ };
+
+ await saveProjectLocally(withSubject, false);
+ expect(put.mock.calls[0]?.[0].tests[0].subject).toBe("");
+
+ await saveProjectLocally(withSubject, false, true);
+ expect(put.mock.calls[1]?.[0].tests[0].subject).toBe("private subject");
+ });
+
+ it("loads one latest record through the updatedAt index cursor", async () => {
+ const cursorRequest = {
+ result: { value: project },
+ error: null,
+ onsuccess: null as ((event: Event) => void) | null,
+ onerror: null as ((event: Event) => void) | null,
+ };
+ const openCursor = vi.fn(() => {
+ queueMicrotask(() => cursorRequest.onsuccess?.(new Event("success")));
+ return cursorRequest as unknown as IDBRequest;
+ });
+ const index = vi.fn(() => ({ openCursor }));
+ const objectStore = vi.fn(() => ({ index }));
+ const close = vi.fn();
+ const database = {
+ transaction: vi.fn(() => ({ objectStore })),
+ close,
+ };
+ const openRequest = {
+ result: database,
+ error: null,
+ transaction: null,
+ onupgradeneeded: null as ((event: Event) => void) | null,
+ onsuccess: null as ((event: Event) => void) | null,
+ onerror: null as ((event: Event) => void) | null,
+ };
+ const open = vi.fn(() => {
+ queueMicrotask(() => openRequest.onsuccess?.(new Event("success")));
+ return openRequest as unknown as IDBOpenDBRequest;
+ });
+ vi.stubGlobal("indexedDB", { open } as unknown as IDBFactory);
+
+ await expect(loadMostRecentProject()).resolves.toMatchObject({
+ id: "project-1",
+ name: "Latest",
+ });
+ expect(index).toHaveBeenCalledWith("updatedAt");
+ expect(openCursor).toHaveBeenCalledWith(null, "prev");
+ expect(close).toHaveBeenCalledOnce();
+ });
+
+ it("rejects an aggregate oversized save before opening IndexedDB", async () => {
+ const open = vi.fn();
+ vi.stubGlobal("indexedDB", { open } as unknown as IDBFactory);
+ const oversized: RegexProjectV1 = {
+ ...project,
+ tests: [
+ {
+ id: "oversized",
+ name: "Oversized",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "a",
+ flags: [],
+ options: {},
+ subject: "\0".repeat(6 * 1024 * 1024),
+ expectation: { kind: "should-match" },
+ },
+ ],
+ };
+
+ await expect(saveProjectLocally(oversized, false, true)).rejects.toThrow(
+ /32 MiB/u,
+ );
+ expect(open).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/project/persistence.ts b/src/project/persistence.ts
new file mode 100644
index 0000000..a1d3833
--- /dev/null
+++ b/src/project/persistence.ts
@@ -0,0 +1,97 @@
+import type { RegexProjectV1 } from "./project.types";
+import { assertProjectDocumentWithinLimit } from "./project-limits";
+import { parseRegexProject } from "./project.validation";
+
+const DATABASE_NAME = "de.add-ideas.regex-tools";
+const STORE_NAME = "projects";
+const UPDATED_AT_INDEX = "updatedAt";
+const DATABASE_VERSION = 2;
+
+function database(): Promise {
+ return new Promise((resolve, reject) => {
+ const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION);
+ request.onupgradeneeded = () => {
+ const db = request.result;
+ const store = db.objectStoreNames.contains(STORE_NAME)
+ ? request.transaction!.objectStore(STORE_NAME)
+ : db.createObjectStore(STORE_NAME, { keyPath: "id" });
+ if (!store.indexNames.contains(UPDATED_AT_INDEX)) {
+ store.createIndex(UPDATED_AT_INDEX, "updatedAt");
+ }
+ };
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () =>
+ reject(request.error ?? new Error("IndexedDB could not be opened."));
+ });
+}
+
+export async function saveProjectLocally(
+ project: RegexProjectV1,
+ includeTestText: boolean,
+ includeUnitTestSubjects = false,
+): Promise {
+ const persisted: RegexProjectV1 = {
+ ...project,
+ updatedAt: new Date().toISOString(),
+ testText: includeTestText
+ ? { included: true, value: project.testText?.value ?? "" }
+ : { included: false },
+ tests: includeUnitTestSubjects
+ ? project.tests
+ : project.tests.map((test) => ({ ...test, subject: "" })),
+ };
+ assertProjectDocumentWithinLimit(persisted);
+ const db = await database();
+ try {
+ await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, "readwrite");
+ transaction.objectStore(STORE_NAME).put(persisted);
+ transaction.oncomplete = () => resolve();
+ transaction.onerror = () =>
+ reject(transaction.error ?? new Error("Project save failed."));
+ });
+ } finally {
+ db.close();
+ }
+}
+
+export async function loadProjectLocally(
+ projectId: string,
+): Promise {
+ const db = await database();
+ try {
+ const value = await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, "readonly");
+ const request = transaction.objectStore(STORE_NAME).get(projectId);
+ request.onsuccess = () => resolve(request.result);
+ request.onerror = () =>
+ reject(request.error ?? new Error("Project load failed."));
+ });
+ if (value === undefined) return null;
+ assertProjectDocumentWithinLimit(value);
+ return parseRegexProject(value);
+ } finally {
+ db.close();
+ }
+}
+
+export async function loadMostRecentProject(): Promise {
+ const db = await database();
+ try {
+ const value = await new Promise((resolve, reject) => {
+ const transaction = db.transaction(STORE_NAME, "readonly");
+ const request = transaction
+ .objectStore(STORE_NAME)
+ .index(UPDATED_AT_INDEX)
+ .openCursor(null, "prev");
+ request.onsuccess = () => resolve(request.result?.value);
+ request.onerror = () =>
+ reject(request.error ?? new Error("Project load failed."));
+ });
+ if (value === undefined) return null;
+ assertProjectDocumentWithinLimit(value);
+ return parseRegexProject(value);
+ } finally {
+ db.close();
+ }
+}
diff --git a/src/project/project-limits.test.ts b/src/project/project-limits.test.ts
new file mode 100644
index 0000000..011526f
--- /dev/null
+++ b/src/project/project-limits.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import {
+ projectDocumentUtf8ByteLength,
+ serializedProjectDocumentUtf8ByteLength,
+} from "./project-limits";
+
+describe("project document byte limits", () => {
+ it("counts the exact UTF-8 bytes emitted by indented JSON", () => {
+ const values: readonly unknown[] = [
+ {},
+ { ascii: "text", control: "\0\n\t", unicode: "Grüße 😀" },
+ [true, false, null, 12.5, Number.POSITIVE_INFINITY],
+ { nested: [{ value: "\ud800" }, { value: "\udc00" }] },
+ ];
+
+ for (const value of values) {
+ const serialized = JSON.stringify(value, null, 2);
+ expect(projectDocumentUtf8ByteLength(value)).toBe(
+ new TextEncoder().encode(serialized).byteLength,
+ );
+ }
+ });
+
+ it("checks UTF-8 bytes rather than JavaScript string length", () => {
+ expect(serializedProjectDocumentUtf8ByteLength("é", 2)).toBe(2);
+ expect(() => serializedProjectDocumentUtf8ByteLength("é", 1)).toThrow(
+ /32 MiB/u,
+ );
+ });
+
+ it("stops before an over-limit JSON document is materialized", () => {
+ const value = { text: "\0".repeat(100) };
+ const exact = new TextEncoder().encode(
+ JSON.stringify(value, null, 2),
+ ).byteLength;
+
+ expect(projectDocumentUtf8ByteLength(value, exact)).toBe(exact);
+ expect(() => projectDocumentUtf8ByteLength(value, exact - 1)).toThrow(
+ /32 MiB/u,
+ );
+ });
+});
diff --git a/src/project/project-limits.ts b/src/project/project-limits.ts
new file mode 100644
index 0000000..2914a4f
--- /dev/null
+++ b/src/project/project-limits.ts
@@ -0,0 +1,219 @@
+export const MAXIMUM_PROJECT_DOCUMENT_BYTES = 32 * 1024 * 1024;
+
+class ProjectDocumentByteCounter {
+ #bytes = 0;
+ readonly #maximumBytes: number;
+
+ constructor(maximumBytes: number) {
+ if (
+ !Number.isSafeInteger(maximumBytes) ||
+ maximumBytes < 0 ||
+ maximumBytes > MAXIMUM_PROJECT_DOCUMENT_BYTES
+ ) {
+ throw new RangeError(
+ `Project document byte limit must be an integer from 0 to ${MAXIMUM_PROJECT_DOCUMENT_BYTES}.`,
+ );
+ }
+ this.#maximumBytes = maximumBytes;
+ }
+
+ add(bytes: number): void {
+ this.#bytes += bytes;
+ if (this.#bytes > this.#maximumBytes) {
+ throw new Error("Project document exceeds the 32 MiB limit.");
+ }
+ }
+
+ get bytes(): number {
+ return this.#bytes;
+ }
+}
+
+function addRawUtf8Bytes(
+ value: string,
+ counter: ProjectDocumentByteCounter,
+): void {
+ for (let index = 0; index < value.length; index += 1) {
+ const first = value.charCodeAt(index);
+ if (first <= 0x7f) {
+ counter.add(1);
+ } else if (first <= 0x7ff) {
+ counter.add(2);
+ } else if (first >= 0xd800 && first <= 0xdbff) {
+ const second = value.charCodeAt(index + 1);
+ if (second >= 0xdc00 && second <= 0xdfff) {
+ counter.add(4);
+ index += 1;
+ } else {
+ counter.add(3);
+ }
+ } else {
+ counter.add(3);
+ }
+ }
+}
+
+function addJsonStringBytes(
+ value: string,
+ counter: ProjectDocumentByteCounter,
+): void {
+ counter.add(2);
+ for (let index = 0; index < value.length; index += 1) {
+ const first = value.charCodeAt(index);
+ if (
+ first === 0x08 ||
+ first === 0x09 ||
+ first === 0x0a ||
+ first === 0x0c ||
+ first === 0x0d ||
+ first === 0x22 ||
+ first === 0x5c
+ ) {
+ counter.add(2);
+ } else if (first <= 0x1f) {
+ counter.add(6);
+ } else if (first <= 0x7f) {
+ counter.add(1);
+ } else if (first <= 0x7ff) {
+ counter.add(2);
+ } else if (first >= 0xd800 && first <= 0xdbff) {
+ const second = value.charCodeAt(index + 1);
+ if (second >= 0xdc00 && second <= 0xdfff) {
+ counter.add(4);
+ index += 1;
+ } else {
+ counter.add(6);
+ }
+ } else if (first >= 0xdc00 && first <= 0xdfff) {
+ counter.add(6);
+ } else {
+ counter.add(3);
+ }
+ }
+}
+
+function omittedByJsonStringify(value: unknown): boolean {
+ return (
+ value === undefined ||
+ typeof value === "function" ||
+ typeof value === "symbol"
+ );
+}
+
+function addIndent(depth: number, counter: ProjectDocumentByteCounter): void {
+ counter.add(depth * 2);
+}
+
+function addJsonValueBytes(
+ value: unknown,
+ depth: number,
+ ancestors: Set,
+ counter: ProjectDocumentByteCounter,
+): void {
+ if (value === null) {
+ counter.add(4);
+ return;
+ }
+ switch (typeof value) {
+ case "string":
+ addJsonStringBytes(value, counter);
+ return;
+ case "boolean":
+ counter.add(value ? 4 : 5);
+ return;
+ case "number":
+ counter.add(
+ Number.isFinite(value) ? (JSON.stringify(value)?.length ?? 4) : 4,
+ );
+ return;
+ case "bigint":
+ throw new TypeError("Project document cannot contain bigint values.");
+ case "undefined":
+ case "function":
+ case "symbol":
+ throw new TypeError("Project document root must be JSON data.");
+ }
+
+ if (ancestors.has(value)) {
+ throw new TypeError("Project document must not contain circular data.");
+ }
+ ancestors.add(value);
+ try {
+ if (Array.isArray(value)) {
+ counter.add(1);
+ if (value.length > 0) {
+ counter.add(1);
+ for (let index = 0; index < value.length; index += 1) {
+ addIndent(depth + 1, counter);
+ const item = value[index];
+ if (omittedByJsonStringify(item)) {
+ counter.add(4);
+ } else {
+ addJsonValueBytes(item, depth + 1, ancestors, counter);
+ }
+ counter.add(index + 1 < value.length ? 2 : 1);
+ }
+ addIndent(depth, counter);
+ }
+ counter.add(1);
+ return;
+ }
+
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ throw new TypeError(
+ "Project document must contain only plain JSON objects and arrays.",
+ );
+ }
+ const entries = Object.keys(value)
+ .map((key) => [key, (value as Record)[key]] as const)
+ .filter((entry) => !omittedByJsonStringify(entry[1]));
+ counter.add(1);
+ if (entries.length > 0) {
+ counter.add(1);
+ for (let index = 0; index < entries.length; index += 1) {
+ const [key, entryValue] = entries[index]!;
+ addIndent(depth + 1, counter);
+ addJsonStringBytes(key, counter);
+ counter.add(2);
+ addJsonValueBytes(entryValue, depth + 1, ancestors, counter);
+ counter.add(index + 1 < entries.length ? 2 : 1);
+ }
+ addIndent(depth, counter);
+ }
+ counter.add(1);
+ } finally {
+ ancestors.delete(value);
+ }
+}
+
+export function projectDocumentUtf8ByteLength(
+ value: unknown,
+ maximumBytes = MAXIMUM_PROJECT_DOCUMENT_BYTES,
+): number {
+ const counter = new ProjectDocumentByteCounter(maximumBytes);
+ addJsonValueBytes(value, 0, new Set(), counter);
+ return counter.bytes;
+}
+
+export function serializedProjectDocumentUtf8ByteLength(
+ serialized: string,
+ maximumBytes = MAXIMUM_PROJECT_DOCUMENT_BYTES,
+): number {
+ const counter = new ProjectDocumentByteCounter(maximumBytes);
+ addRawUtf8Bytes(serialized, counter);
+ return counter.bytes;
+}
+
+export function assertProjectDocumentWithinLimit(value: unknown): number {
+ return projectDocumentUtf8ByteLength(value, MAXIMUM_PROJECT_DOCUMENT_BYTES);
+}
+
+export function assertSerializedProjectDocumentWithinLimit(
+ serialized: string,
+): number {
+ return serializedProjectDocumentUtf8ByteLength(
+ serialized,
+ MAXIMUM_PROJECT_DOCUMENT_BYTES,
+ );
+}
diff --git a/src/project/project.serialization.ts b/src/project/project.serialization.ts
new file mode 100644
index 0000000..68ea672
--- /dev/null
+++ b/src/project/project.serialization.ts
@@ -0,0 +1,50 @@
+import type { ProjectExportOptions, RegexProjectV1 } from "./project.types";
+import {
+ assertSerializedProjectDocumentWithinLimit,
+ MAXIMUM_PROJECT_DOCUMENT_BYTES,
+ projectDocumentUtf8ByteLength,
+} from "./project-limits";
+import { parseRegexProject } from "./project.validation";
+
+export function serializeProject(
+ project: RegexProjectV1,
+ options: ProjectExportOptions,
+): string {
+ const document: RegexProjectV1 = {
+ ...project,
+ updatedAt: new Date().toISOString(),
+ testText: options.includeTestText
+ ? {
+ included: true,
+ value: project.testText?.value ?? "",
+ }
+ : { included: false },
+ tests: options.includeUnitTestSubjects
+ ? project.tests
+ : project.tests.map((test) => ({ ...test, subject: "" })),
+ };
+ projectDocumentUtf8ByteLength(document, MAXIMUM_PROJECT_DOCUMENT_BYTES - 1);
+ const serialized = `${JSON.stringify(document, null, 2)}\n`;
+ assertSerializedProjectDocumentWithinLimit(serialized);
+ return serialized;
+}
+
+export function importProjectDocument(serialized: string): RegexProjectV1 {
+ assertSerializedProjectDocumentWithinLimit(serialized);
+ let value: unknown;
+ try {
+ value = JSON.parse(serialized);
+ } catch (error) {
+ throw new Error("Project document is not valid JSON.", { cause: error });
+ }
+ return parseRegexProject(value);
+}
+
+export function projectFileName(project: RegexProjectV1): string {
+ const safe = project.name
+ .normalize("NFKD")
+ .replace(/[^\p{Letter}\p{Number}._-]+/gu, "-")
+ .replace(/^-+|-+$/gu, "")
+ .slice(0, 80);
+ return `${safe || "regex-project"}.regex-tools.json`;
+}
diff --git a/src/project/project.test.ts b/src/project/project.test.ts
new file mode 100644
index 0000000..59c068c
--- /dev/null
+++ b/src/project/project.test.ts
@@ -0,0 +1,246 @@
+import { describe, expect, it } from "vitest";
+import {
+ importProjectDocument,
+ serializeProject,
+} from "./project.serialization";
+import type { RegexProjectV1 } from "./project.types";
+import { DEFAULT_REGEX_LIMITS } from "../regex/execution/request-limits";
+
+const project: RegexProjectV1 = {
+ schemaVersion: 1,
+ id: "project-1",
+ name: "Dates",
+ createdAt: "2026-07-24T00:00:00.000Z",
+ updatedAt: "2026-07-24T00:00:00.000Z",
+ flavour: "ecmascript",
+ pattern: "\\d+",
+ flags: ["g"],
+ options: {},
+ replacement: "$&",
+ mode: "match",
+ testText: { included: true, value: "secret 42" },
+ listTemplate: { source: "$0", tokens: [{ kind: "full-match" }] },
+ tests: [
+ {
+ id: "test-1",
+ name: "matches",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "\\d+",
+ flags: [],
+ options: {},
+ subject: "42",
+ expectation: { kind: "should-match" },
+ },
+ ],
+ ui: { live: true, scanAll: false },
+};
+
+describe("project import and export", () => {
+ it("excludes sensitive text by default and validates round trips", () => {
+ const serialized = serializeProject(project, {
+ includeTestText: false,
+ includeUnitTestSubjects: false,
+ });
+ expect(serialized).not.toContain("secret 42");
+ const imported = importProjectDocument(serialized);
+ expect(imported.testText).toEqual({ included: false });
+ expect(imported.tests[0]?.subject).toBe("");
+ expect(imported.pattern).toBe("\\d+");
+ });
+
+ it("includes requested text explicitly", () => {
+ const serialized = serializeProject(project, {
+ includeTestText: true,
+ includeUnitTestSubjects: true,
+ });
+ expect(serialized).toContain("secret 42");
+ expect(serialized).toContain('"subject": "42"');
+ });
+
+ it("preflights aggregate export size after applying privacy options", () => {
+ const escapedSubject = "\0".repeat(2 * 1024 * 1024);
+ const aggregateProject: RegexProjectV1 = {
+ ...project,
+ tests: Array.from({ length: 3 }, (_, index) => ({
+ ...project.tests[0]!,
+ id: `aggregate-${index}`,
+ subject: escapedSubject,
+ })),
+ };
+
+ expect(() =>
+ serializeProject(aggregateProject, {
+ includeTestText: false,
+ includeUnitTestSubjects: true,
+ }),
+ ).toThrow(/32 MiB/u);
+ expect(() =>
+ serializeProject(aggregateProject, {
+ includeTestText: false,
+ includeUnitTestSubjects: false,
+ }),
+ ).not.toThrow();
+ });
+
+ it("validates richer versioned test expectations", () => {
+ const serialized = JSON.stringify({
+ ...project,
+ tests: [
+ {
+ ...project.tests[0],
+ expectation: {
+ kind: "capture",
+ matchIndex: 0,
+ groupName: "number",
+ status: "participated",
+ value: "42",
+ },
+ },
+ {
+ ...project.tests[0],
+ id: "replacement-test",
+ replacement: "[$&]",
+ expectation: { kind: "replacement", expected: "[42]" },
+ },
+ ],
+ });
+ const imported = importProjectDocument(serialized);
+ expect(imported.tests[0]?.expectation).toEqual(
+ expect.objectContaining({ kind: "capture", groupName: "number" }),
+ );
+ expect(imported.tests[1]?.expectation).toEqual({
+ kind: "replacement",
+ expected: "[42]",
+ });
+ });
+
+ it("rejects future schemas, invalid flags and oversized imports", () => {
+ expect(() => importProjectDocument('{"schemaVersion":2}')).toThrow(
+ /schema version 1/u,
+ );
+ expect(() =>
+ importProjectDocument(JSON.stringify({ ...project, flags: ["u", "v"] })),
+ ).toThrow(/cannot be combined/u);
+ expect(() =>
+ importProjectDocument(" ".repeat(32 * 1024 * 1024 + 1)),
+ ).toThrow(/32 MiB/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ replacement: "$&".repeat(
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 / 2 + 1,
+ ),
+ }),
+ ),
+ ).toThrow(/Replacement exceeds/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ listTemplate: {
+ source: "$0".repeat(
+ DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16 / 2 + 1,
+ ),
+ },
+ }),
+ ),
+ ).toThrow(/List template exceeds/u);
+ const astralSubject = "😀".repeat(
+ DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes / 4 + 1,
+ );
+ expect(astralSubject.length).toBeLessThan(
+ DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes,
+ );
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ testText: { included: true, value: astralSubject },
+ }),
+ ),
+ ).toThrow(/Test text exceeds.*UTF-8 byte limit/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [{ ...project.tests[0], subject: astralSubject }],
+ }),
+ ),
+ ).toThrow(/Test 1 subject exceeds.*UTF-8 byte limit/u);
+ });
+
+ it("rejects unsupported flavour and option semantics instead of rewriting them", () => {
+ expect(() =>
+ importProjectDocument(JSON.stringify({ ...project, flavour: "pcre2" })),
+ ).toThrow(/only supports ECMAScript/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [{ ...project.tests[0], flavour: "python" }],
+ }),
+ ),
+ ).toThrow(/only supports ECMAScript/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({ ...project, options: { ignoreCase: true } }),
+ ),
+ ).toThrow(/does not implement ECMAScript engine options/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [{ ...project.tests[0], options: { ascii: true } }],
+ }),
+ ),
+ ).toThrow(/does not implement ECMAScript engine options/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ resourceOverrides: { maximumMatches: 12 },
+ }),
+ ),
+ ).toThrow(/resource overrides are not implemented/u);
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [project.tests[0], { ...project.tests[0] }],
+ }),
+ ),
+ ).toThrow(/test id.*duplicated/iu);
+ });
+
+ it("validates and preserves the supported per-test timeout limit", () => {
+ const imported = importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [
+ {
+ ...project.tests[0],
+ resourceLimits: { manualExecutionTimeoutMs: 750 },
+ },
+ ],
+ }),
+ );
+ expect(imported.tests[0]?.resourceLimits).toEqual({
+ manualExecutionTimeoutMs: 750,
+ });
+ expect(() =>
+ importProjectDocument(
+ JSON.stringify({
+ ...project,
+ tests: [
+ {
+ ...project.tests[0],
+ resourceLimits: { maximumMatches: 1 },
+ },
+ ],
+ }),
+ ),
+ ).toThrow(/unsupported entries/u);
+ });
+});
diff --git a/src/project/project.types.ts b/src/project/project.types.ts
new file mode 100644
index 0000000..ecfc74d
--- /dev/null
+++ b/src/project/project.types.ts
@@ -0,0 +1,37 @@
+import type { RegexFlavourId } from "../regex/model/flavour";
+import type { RegexResourceLimits } from "../regex/execution/request-limits";
+import type { ListTemplate } from "../regex/list/list-template";
+import type { RegexTestCase } from "../regex/tests/test-case.types";
+
+export type WorkspaceMode = "match" | "replace" | "list" | "tests";
+
+export interface RegexProjectV1 {
+ readonly schemaVersion: 1;
+ readonly id: string;
+ readonly name: string;
+ readonly createdAt: string;
+ readonly updatedAt: string;
+ readonly flavour: RegexFlavourId;
+ readonly flavourVersion?: string;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly options: Readonly>;
+ readonly replacement?: string;
+ readonly mode: WorkspaceMode;
+ readonly testText?: {
+ readonly included: boolean;
+ readonly value?: string;
+ };
+ readonly listTemplate?: ListTemplate;
+ readonly tests: readonly RegexTestCase[];
+ readonly resourceOverrides?: Partial;
+ readonly ui?: {
+ readonly live: boolean;
+ readonly scanAll: boolean;
+ };
+}
+
+export interface ProjectExportOptions {
+ readonly includeTestText: boolean;
+ readonly includeUnitTestSubjects: boolean;
+}
diff --git a/src/project/project.validation.ts b/src/project/project.validation.ts
new file mode 100644
index 0000000..b193656
--- /dev/null
+++ b/src/project/project.validation.ts
@@ -0,0 +1,398 @@
+import type { RegexProjectV1, WorkspaceMode } from "./project.types";
+import type {
+ RegexTestCase,
+ RegexTestExpectation,
+} from "../regex/tests/test-case.types";
+import {
+ MAXIMUM_REGEX_TEST_CAPTURE_NAME_UTF16,
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ MAXIMUM_REGEX_TEST_NAME_UTF16,
+ MAXIMUM_REGEX_TEST_PATTERN_UTF16,
+ MAXIMUM_REGEX_TEST_SUBJECT_UTF16,
+ MAXIMUM_REGEX_TESTS,
+} from "../regex/tests/test-case.types";
+import { parseListTemplate } from "../regex/list/list-template";
+import {
+ DEFAULT_REGEX_LIMITS,
+ utf8ByteLength,
+} from "../regex/execution/request-limits";
+import { assertProjectDocumentWithinLimit } from "./project-limits";
+
+const IMPLEMENTED_MODES = new Set([
+ "match",
+ "replace",
+ "list",
+ "tests",
+]);
+const VALID_FLAGS = new Set("dgimsuvy".split(""));
+const MAXIMUM_PATTERN_UTF16 = MAXIMUM_REGEX_TEST_PATTERN_UTF16;
+const MAXIMUM_SUBJECT_UTF16 = MAXIMUM_REGEX_TEST_SUBJECT_UTF16;
+
+function objectValue(
+ value: unknown,
+ label = "Project",
+): Record {
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error(`${label} must be a JSON object.`);
+ }
+ return value as Record;
+}
+
+function stringValue(value: unknown, label: string, maximum: number): string {
+ if (typeof value !== "string") throw new Error(`${label} must be text.`);
+ if (value.length > maximum) {
+ throw new Error(`${label} exceeds the ${maximum} UTF-16 unit limit.`);
+ }
+ return value;
+}
+
+function subjectValue(value: unknown, label: string): string {
+ const subject = stringValue(value, label, MAXIMUM_SUBJECT_UTF16);
+ if (
+ utf8ByteLength(subject) > DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes
+ ) {
+ throw new Error(
+ `${label} exceeds the ${DEFAULT_REGEX_LIMITS.interactiveSubjectHardBytes.toLocaleString()} UTF-8 byte limit.`,
+ );
+ }
+ return subject;
+}
+
+function parseFlags(value: unknown): readonly string[] {
+ if (!Array.isArray(value)) throw new Error("Project flags must be an array.");
+ const flags = value.map((flag) => stringValue(flag, "Flag", 1));
+ if (
+ new Set(flags).size !== flags.length ||
+ flags.some((flag) => !VALID_FLAGS.has(flag))
+ ) {
+ throw new Error("Project flags contain duplicates or unsupported values.");
+ }
+ if (flags.includes("u") && flags.includes("v")) {
+ throw new Error("ECMAScript flags u and v cannot be combined.");
+ }
+ return flags;
+}
+
+function parseFlavour(value: unknown, label: string): "ecmascript" {
+ const flavour = stringValue(value, label, 32);
+ if (flavour !== "ecmascript") {
+ throw new Error(
+ `${label} ${JSON.stringify(flavour)} is unsupported; version 0.1.0 only supports ECMAScript.`,
+ );
+ }
+ return flavour;
+}
+
+function parseOptions(
+ value: unknown,
+ label: string,
+): Readonly> {
+ const options = value === undefined ? {} : objectValue(value, label);
+ if (Object.keys(options).length > 0) {
+ throw new Error(
+ `${label} contains unsupported entries; version 0.1.0 does not implement ECMAScript engine options.`,
+ );
+ }
+ return {};
+}
+
+function parseTestResourceLimits(
+ value: unknown,
+ label: string,
+): RegexTestCase["resourceLimits"] {
+ if (value === undefined) return undefined;
+ const limits = objectValue(value, label);
+ const unsupported = Object.keys(limits).filter(
+ (key) => key !== "manualExecutionTimeoutMs",
+ );
+ if (unsupported.length > 0) {
+ throw new Error(
+ `${label} contains unsupported entries: ${unsupported.join(", ")}.`,
+ );
+ }
+ const timeout = limits.manualExecutionTimeoutMs;
+ if (timeout === undefined) return {};
+ if (
+ typeof timeout !== "number" ||
+ !Number.isSafeInteger(timeout) ||
+ timeout < 1 ||
+ timeout > 10_000
+ ) {
+ throw new Error(
+ `${label} manualExecutionTimeoutMs must be an integer from 1 to 10000.`,
+ );
+ }
+ return { manualExecutionTimeoutMs: timeout };
+}
+
+export function parseRegexTests(value: unknown): readonly RegexTestCase[] {
+ if (!Array.isArray(value)) throw new Error("Project tests must be an array.");
+ if (value.length > MAXIMUM_REGEX_TESTS) {
+ throw new Error(`Project contains more than ${MAXIMUM_REGEX_TESTS} tests.`);
+ }
+ const testIds = new Set();
+ return value.map((candidate, index) => {
+ const test = objectValue(candidate, `Test ${index + 1}`);
+ const flavour = parseFlavour(test.flavour, `Test ${index + 1} flavour`);
+ const options = parseOptions(test.options, `Test ${index + 1} options`);
+ const resourceLimits = parseTestResourceLimits(
+ test.resourceLimits,
+ `Test ${index + 1} resource limits`,
+ );
+ const expectation = objectValue(
+ test.expectation,
+ `Test ${index + 1} expectation`,
+ );
+ const kind = stringValue(
+ expectation.kind,
+ `Test ${index + 1} expectation`,
+ 32,
+ );
+ const integer = (
+ candidate: unknown,
+ label: string,
+ maximum = 1_000_000,
+ ) => {
+ if (
+ typeof candidate !== "number" ||
+ !Number.isSafeInteger(candidate) ||
+ candidate < 0 ||
+ candidate > maximum
+ ) {
+ throw new Error(`${label} must be a bounded non-negative integer.`);
+ }
+ return candidate;
+ };
+ let parsedExpectation: RegexTestExpectation;
+ switch (kind) {
+ case "should-match":
+ case "should-not-match":
+ parsedExpectation = { kind };
+ break;
+ case "match-count":
+ parsedExpectation = {
+ kind,
+ count: integer(expectation.count, "Expected match count"),
+ };
+ break;
+ case "full-match":
+ parsedExpectation = {
+ kind,
+ matchIndex: integer(expectation.matchIndex, "Match index"),
+ value: stringValue(
+ expectation.value,
+ "Expected full match",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ ),
+ };
+ break;
+ case "capture": {
+ const status = stringValue(
+ expectation.status,
+ "Expected capture status",
+ 32,
+ );
+ if (
+ !["participated", "did-not-participate", "matched-empty"].includes(
+ status,
+ )
+ ) {
+ throw new Error("Expected capture status is unsupported.");
+ }
+ const groupNumber =
+ expectation.groupNumber === undefined
+ ? undefined
+ : integer(expectation.groupNumber, "Capture number", 1_000);
+ const groupName =
+ expectation.groupName === undefined
+ ? undefined
+ : stringValue(
+ expectation.groupName,
+ "Capture name",
+ MAXIMUM_REGEX_TEST_CAPTURE_NAME_UTF16,
+ );
+ if (groupNumber === undefined && groupName === undefined) {
+ throw new Error("Capture expectation needs a group number or name.");
+ }
+ parsedExpectation = {
+ kind,
+ matchIndex: integer(expectation.matchIndex, "Match index"),
+ ...(groupNumber === undefined ? {} : { groupNumber }),
+ ...(groupName === undefined ? {} : { groupName }),
+ status: status as
+ "participated" | "did-not-participate" | "matched-empty",
+ ...(expectation.value === undefined
+ ? {}
+ : {
+ value: stringValue(
+ expectation.value,
+ "Expected capture value",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ ),
+ }),
+ };
+ break;
+ }
+ case "replacement":
+ parsedExpectation = {
+ kind,
+ expected: stringValue(
+ expectation.expected,
+ "Expected replacement",
+ MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+ ),
+ };
+ break;
+ case "must-complete-within":
+ parsedExpectation = {
+ kind,
+ milliseconds: integer(
+ expectation.milliseconds,
+ "Completion limit",
+ 10_000,
+ ),
+ };
+ break;
+ case "must-time-out":
+ parsedExpectation = { kind };
+ break;
+ default:
+ throw new Error(
+ `Imported test ${index + 1} uses unsupported expectation ${kind}.`,
+ );
+ }
+ const id = stringValue(test.id, `Test ${index + 1} id`, 128);
+ if (id.length === 0) {
+ throw new Error(`Test ${index + 1} id must not be empty.`);
+ }
+ if (testIds.has(id)) {
+ throw new Error(`Project test id ${JSON.stringify(id)} is duplicated.`);
+ }
+ testIds.add(id);
+ return {
+ id,
+ name: stringValue(
+ test.name,
+ `Test ${index + 1} name`,
+ MAXIMUM_REGEX_TEST_NAME_UTF16,
+ ),
+ enabled: test.enabled !== false,
+ flavour,
+ ...(test.flavourVersion === undefined
+ ? {}
+ : {
+ flavourVersion: stringValue(
+ test.flavourVersion,
+ `Test ${index + 1} flavour version`,
+ 128,
+ ),
+ }),
+ pattern: stringValue(
+ test.pattern,
+ `Test ${index + 1} pattern`,
+ MAXIMUM_REGEX_TEST_PATTERN_UTF16,
+ ),
+ flags: parseFlags(test.flags),
+ options,
+ ...(test.scanAll === undefined
+ ? {}
+ : typeof test.scanAll === "boolean"
+ ? { scanAll: test.scanAll }
+ : (() => {
+ throw new Error(`Test ${index + 1} scanAll must be boolean.`);
+ })()),
+ subject: subjectValue(test.subject, `Test ${index + 1} subject`),
+ ...(test.replacement === undefined
+ ? {}
+ : {
+ replacement: stringValue(
+ test.replacement,
+ `Test ${index + 1} replacement`,
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16,
+ ),
+ }),
+ ...(resourceLimits === undefined ? {} : { resourceLimits }),
+ expectation: parsedExpectation,
+ };
+ });
+}
+
+export function parseRegexProject(value: unknown): RegexProjectV1 {
+ const input = objectValue(value);
+ if (input.schemaVersion !== 1) {
+ throw new Error("Only regex-tools project schema version 1 is supported.");
+ }
+ const flavour = parseFlavour(input.flavour, "Project flavour");
+ const options = parseOptions(input.options, "Project options");
+ if (input.resourceOverrides !== undefined) {
+ const overrides = objectValue(
+ input.resourceOverrides,
+ "Project resource overrides",
+ );
+ if (Object.keys(overrides).length > 0) {
+ throw new Error(
+ "Project resource overrides are not implemented in version 0.1.0.",
+ );
+ }
+ }
+ const mode = stringValue(input.mode, "Project mode", 16) as WorkspaceMode;
+ if (!IMPLEMENTED_MODES.has(mode)) {
+ throw new Error(`Project mode ${mode} is not implemented in this release.`);
+ }
+ const testTextInput =
+ input.testText === undefined ? undefined : objectValue(input.testText);
+ const listSource =
+ input.listTemplate && typeof input.listTemplate === "object"
+ ? stringValue(
+ (input.listTemplate as Record).source,
+ "List template",
+ DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16,
+ )
+ : "$0";
+ const now = new Date().toISOString();
+ const project: RegexProjectV1 = {
+ schemaVersion: 1,
+ id: stringValue(input.id, "Project id", 128),
+ name: stringValue(input.name, "Project name", 256),
+ createdAt:
+ typeof input.createdAt === "string" ? input.createdAt.slice(0, 64) : now,
+ updatedAt:
+ typeof input.updatedAt === "string" ? input.updatedAt.slice(0, 64) : now,
+ flavour,
+ flavourVersion: "ECMAScript 2025 syntax / current browser runtime",
+ pattern: stringValue(input.pattern, "Pattern", MAXIMUM_PATTERN_UTF16),
+ flags: parseFlags(input.flags),
+ options,
+ replacement:
+ input.replacement === undefined
+ ? ""
+ : stringValue(
+ input.replacement,
+ "Replacement",
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16,
+ ),
+ mode,
+ testText: {
+ included: testTextInput?.included === true,
+ ...(testTextInput?.included === true
+ ? {
+ value: subjectValue(testTextInput.value ?? "", "Test text"),
+ }
+ : {}),
+ },
+ listTemplate: parseListTemplate(listSource),
+ tests: parseRegexTests(input.tests ?? []),
+ ui: {
+ live:
+ typeof input.ui === "object" &&
+ input.ui !== null &&
+ (input.ui as Record).live === true,
+ scanAll:
+ typeof input.ui === "object" &&
+ input.ui !== null &&
+ (input.ui as Record).scanAll === true,
+ },
+ };
+ assertProjectDocumentWithinLimit(project);
+ return project;
+}
diff --git a/src/regex/execution/EngineSupervisor.ts b/src/regex/execution/EngineSupervisor.ts
new file mode 100644
index 0000000..c671a64
--- /dev/null
+++ b/src/regex/execution/EngineSupervisor.ts
@@ -0,0 +1,73 @@
+import type {
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementRequest,
+ RegexReplacementResult,
+} from "../model/match";
+import { WorkerSupervisor } from "./WorkerSupervisor";
+import type {
+ EngineWorkerOperation,
+ EngineWorkerResult,
+} from "./worker-protocol";
+
+export type EngineRuntimeState =
+ | { readonly status: "unloaded" }
+ | { readonly status: "running"; readonly operation: "execute" | "replace" }
+ | { readonly status: "ready" }
+ | { readonly status: "recovering"; readonly reason: string }
+ | { readonly status: "failed"; readonly error: Error };
+
+export class EngineSupervisor {
+ private readonly supervisor = new WorkerSupervisor<
+ EngineWorkerOperation,
+ EngineWorkerResult
+ >(
+ "ECMAScript engine",
+ () =>
+ new Worker(
+ new URL("../../workers/ecmascript.worker.ts", import.meta.url),
+ {
+ type: "module",
+ name: "regex-tools-ecmascript",
+ },
+ ),
+ );
+
+ async execute(
+ request: RegexExecutionRequest,
+ timeoutMs: number,
+ ): Promise {
+ const response = await this.supervisor.run(
+ { kind: "execute", request },
+ timeoutMs,
+ { supersede: true },
+ );
+ if (response.kind !== "execute") {
+ throw new Error("Engine worker returned the wrong response kind");
+ }
+ return response.result;
+ }
+
+ async replace(
+ request: RegexReplacementRequest,
+ timeoutMs: number,
+ ): Promise {
+ const response = await this.supervisor.run(
+ { kind: "replace", request },
+ timeoutMs,
+ { supersede: true },
+ );
+ if (response.kind !== "replace") {
+ throw new Error("Engine worker returned the wrong response kind");
+ }
+ return response.result;
+ }
+
+ cancel(): void {
+ this.supervisor.cancel();
+ }
+
+ dispose(): void {
+ this.supervisor.dispose();
+ }
+}
diff --git a/src/regex/execution/RegexEngineAdapter.ts b/src/regex/execution/RegexEngineAdapter.ts
new file mode 100644
index 0000000..82c692d
--- /dev/null
+++ b/src/regex/execution/RegexEngineAdapter.ts
@@ -0,0 +1,15 @@
+import type {
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementRequest,
+ RegexReplacementResult,
+} from "../model/match";
+import type { RegexEngineInfo, RegexFlavourId } from "../model/flavour";
+
+export interface RegexEngineAdapter {
+ readonly flavour: RegexFlavourId;
+ load(): Promise;
+ execute(request: RegexExecutionRequest): Promise;
+ replace(request: RegexReplacementRequest): Promise;
+ terminate(): void;
+}
diff --git a/src/regex/execution/SyntaxSupervisor.ts b/src/regex/execution/SyntaxSupervisor.ts
new file mode 100644
index 0000000..ee41341
--- /dev/null
+++ b/src/regex/execution/SyntaxSupervisor.ts
@@ -0,0 +1,71 @@
+import type {
+ RegexSyntaxRequest,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+} from "../model/syntax";
+import type { ReplacementSyntaxRequest } from "../syntax/SyntaxProvider";
+import { WorkerSupervisor } from "./WorkerSupervisor";
+import type {
+ SyntaxWorkerOperation,
+ SyntaxWorkerResult,
+} from "./worker-protocol";
+
+export class SyntaxSupervisor {
+ private readonly supervisor: WorkerSupervisor<
+ SyntaxWorkerOperation,
+ SyntaxWorkerResult
+ >;
+
+ constructor(label = "Syntax parser", workerName = "regex-tools-syntax") {
+ this.supervisor = new WorkerSupervisor<
+ SyntaxWorkerOperation,
+ SyntaxWorkerResult
+ >(label, () => {
+ return new Worker(
+ new URL("../../workers/syntax.worker.ts", import.meta.url),
+ {
+ type: "module",
+ name: workerName,
+ },
+ );
+ });
+ }
+
+ async parsePattern(
+ request: RegexSyntaxRequest,
+ timeoutMs = 1_000,
+ ): Promise {
+ const response = await this.supervisor.run(
+ { kind: "parse-pattern", request },
+ timeoutMs,
+ { supersede: true },
+ );
+ if (response.kind !== "parse-pattern") {
+ throw new Error("Syntax worker returned the wrong response kind");
+ }
+ return response.result;
+ }
+
+ async parseReplacement(
+ request: ReplacementSyntaxRequest,
+ timeoutMs = 1_000,
+ ): Promise {
+ const response = await this.supervisor.run(
+ { kind: "parse-replacement", request },
+ timeoutMs,
+ { supersede: true },
+ );
+ if (response.kind !== "parse-replacement") {
+ throw new Error("Syntax worker returned the wrong response kind");
+ }
+ return response.result;
+ }
+
+ cancel(): void {
+ this.supervisor.cancel();
+ }
+
+ dispose(): void {
+ this.supervisor.dispose();
+ }
+}
diff --git a/src/regex/execution/WorkerSupervisor.test.ts b/src/regex/execution/WorkerSupervisor.test.ts
new file mode 100644
index 0000000..e3641da
--- /dev/null
+++ b/src/regex/execution/WorkerSupervisor.test.ts
@@ -0,0 +1,125 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { WorkerSupervisor, type WorkerLike } from "./WorkerSupervisor";
+import { WORKER_PROTOCOL_VERSION, type WorkerRequest } from "./worker-protocol";
+
+class FakeWorker implements WorkerLike {
+ onmessage: ((event: MessageEvent) => void) | null = null;
+ onerror: ((event: ErrorEvent) => void) | null = null;
+ onmessageerror: ((event: MessageEvent) => void) | null = null;
+ readonly messages: unknown[] = [];
+ terminateCalls = 0;
+
+ postMessage(message: unknown): void {
+ this.messages.push(message);
+ }
+
+ terminate(): void {
+ this.terminateCalls += 1;
+ }
+
+ succeed(payload: string): void {
+ const request = this.messages.at(-1) as WorkerRequest;
+ this.onmessage?.(
+ new MessageEvent("message", {
+ data: {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: request.requestId,
+ generation: request.generation,
+ ok: true,
+ payload,
+ },
+ }),
+ );
+ }
+}
+
+afterEach(() => {
+ vi.useRealTimers();
+});
+
+describe("WorkerSupervisor", () => {
+ it("terminates a timed-out worker and succeeds with a fresh generation", async () => {
+ vi.useFakeTimers();
+ const workers: FakeWorker[] = [];
+ const supervisor = new WorkerSupervisor(
+ "test worker",
+ () => {
+ const worker = new FakeWorker();
+ workers.push(worker);
+ return worker;
+ },
+ );
+
+ const first = supervisor.run("hang", 25);
+ const firstExpectation = expect(first).rejects.toMatchObject({
+ kind: "timeout",
+ });
+ await vi.advanceTimersByTimeAsync(25);
+ await firstExpectation;
+ expect(workers[0]?.terminateCalls).toBe(1);
+
+ const second = supervisor.run("ok", 25);
+ expect(workers).toHaveLength(2);
+ workers[1]?.succeed("recovered");
+ await expect(second).resolves.toBe("recovered");
+ expect(supervisor.currentGeneration).toBe(2);
+ supervisor.dispose();
+ });
+
+ it("supersedes an active request and ignores the stale worker", async () => {
+ const workers: FakeWorker[] = [];
+ const supervisor = new WorkerSupervisor(
+ "test worker",
+ () => {
+ const worker = new FakeWorker();
+ workers.push(worker);
+ return worker;
+ },
+ );
+ const firstWorkerMessage: {
+ current?: ((event: MessageEvent) => void) | null;
+ } = {};
+ const first = supervisor.run("first", 1_000);
+ firstWorkerMessage.current = workers[0]?.onmessage;
+ const second = supervisor.run("second", 1_000, { supersede: true });
+ await expect(first).rejects.toMatchObject({ kind: "cancelled" });
+ expect(workers[0]?.terminateCalls).toBe(1);
+
+ const oldRequest = workers[0]?.messages[0] as WorkerRequest;
+ firstWorkerMessage.current?.(
+ new MessageEvent("message", {
+ data: {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: oldRequest.requestId,
+ generation: oldRequest.generation,
+ ok: true,
+ payload: "stale",
+ },
+ }),
+ );
+ workers[1]?.succeed("fresh");
+ await expect(second).resolves.toBe("fresh");
+ supervisor.dispose();
+ });
+
+ it("restarts after a crash", async () => {
+ const workers: FakeWorker[] = [];
+ const supervisor = new WorkerSupervisor(
+ "test worker",
+ () => {
+ const worker = new FakeWorker();
+ workers.push(worker);
+ return worker;
+ },
+ );
+ const first = supervisor.run("crash", 1_000);
+ workers[0]?.onerror?.(
+ new ErrorEvent("error", { message: "simulated crash" }),
+ );
+ await expect(first).rejects.toMatchObject({ kind: "crash" });
+ const second = supervisor.run("next", 1_000);
+ workers[1]?.succeed("ok");
+ await expect(second).resolves.toBe("ok");
+ supervisor.dispose();
+ });
+});
diff --git a/src/regex/execution/WorkerSupervisor.ts b/src/regex/execution/WorkerSupervisor.ts
new file mode 100644
index 0000000..471e3d4
--- /dev/null
+++ b/src/regex/execution/WorkerSupervisor.ts
@@ -0,0 +1,223 @@
+import {
+ WORKER_PROTOCOL_VERSION,
+ type WorkerRequest,
+ type WorkerResponse,
+} from "./worker-protocol";
+
+export interface WorkerLike {
+ onmessage: ((event: MessageEvent) => void) | null;
+ onerror: ((event: ErrorEvent) => void) | null;
+ onmessageerror: ((event: MessageEvent) => void) | null;
+ postMessage(message: unknown): void;
+ terminate(): void;
+}
+
+export type WorkerFactory = () => WorkerLike;
+
+export class WorkerRequestError extends Error {
+ readonly kind: "timeout" | "crash" | "cancelled" | "worker-error";
+
+ constructor(
+ kind: WorkerRequestError["kind"],
+ message: string,
+ options?: ErrorOptions,
+ ) {
+ super(message, options);
+ this.name = "WorkerRequestError";
+ this.kind = kind;
+ }
+}
+
+interface ActiveRequest {
+ readonly requestId: number;
+ readonly generation: number;
+ readonly resolve: (result: TResult) => void;
+ readonly reject: (error: Error) => void;
+ readonly timeout: number;
+}
+
+export class WorkerSupervisor {
+ private readonly label: string;
+ private readonly workerFactory: WorkerFactory;
+ private worker: WorkerLike | null = null;
+ private generation = 0;
+ private nextRequestId = 1;
+ private active: ActiveRequest | null = null;
+ private disposed = false;
+
+ constructor(label: string, workerFactory: WorkerFactory) {
+ this.label = label;
+ this.workerFactory = workerFactory;
+ }
+
+ get currentGeneration(): number {
+ return this.generation;
+ }
+
+ get isRunning(): boolean {
+ return this.active !== null;
+ }
+
+ run(
+ operation: TOperation,
+ timeoutMs: number,
+ options: { readonly supersede?: boolean } = {},
+ ): Promise {
+ if (this.disposed) {
+ return Promise.reject(
+ new WorkerRequestError(
+ "worker-error",
+ `${this.label} supervisor has been disposed`,
+ ),
+ );
+ }
+ if (this.active) {
+ if (!options.supersede) {
+ return Promise.reject(
+ new WorkerRequestError(
+ "worker-error",
+ `${this.label} already has an active request`,
+ ),
+ );
+ }
+ this.restart(
+ new WorkerRequestError(
+ "cancelled",
+ `${this.label} request was superseded`,
+ ),
+ );
+ }
+ const worker = this.ensureWorker();
+ const requestId = this.nextRequestId++;
+ const generation = this.generation;
+ return new Promise((resolve, reject) => {
+ const timeout = globalThis.setTimeout(() => {
+ if (
+ this.active?.requestId !== requestId ||
+ this.active.generation !== generation
+ ) {
+ return;
+ }
+ this.restart(
+ new WorkerRequestError(
+ "timeout",
+ `${this.label} exceeded its ${timeoutMs} ms time limit`,
+ ),
+ );
+ }, timeoutMs);
+ this.active = {
+ requestId,
+ generation,
+ resolve,
+ reject,
+ timeout,
+ };
+ const message: WorkerRequest = {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId,
+ generation,
+ payload: operation,
+ };
+ worker.postMessage(message);
+ });
+ }
+
+ cancel(): void {
+ if (!this.active) return;
+ this.restart(
+ new WorkerRequestError(
+ "cancelled",
+ `${this.label} request was cancelled`,
+ ),
+ );
+ }
+
+ dispose(): void {
+ if (this.disposed) return;
+ this.disposed = true;
+ this.restart(
+ new WorkerRequestError("cancelled", `${this.label} was disposed`),
+ );
+ }
+
+ private ensureWorker(): WorkerLike {
+ if (this.worker) return this.worker;
+ const worker = this.workerFactory();
+ this.generation += 1;
+ const generation = this.generation;
+ worker.onmessage = (event) => {
+ this.handleResponse(event.data, generation);
+ };
+ worker.onerror = (event) => {
+ this.handleCrash(
+ new WorkerRequestError(
+ "crash",
+ `${this.label} crashed: ${event.message || "unknown worker error"}`,
+ ),
+ generation,
+ );
+ };
+ worker.onmessageerror = () => {
+ this.handleCrash(
+ new WorkerRequestError(
+ "crash",
+ `${this.label} returned an unreadable message`,
+ ),
+ generation,
+ );
+ };
+ this.worker = worker;
+ return worker;
+ }
+
+ private handleResponse(value: unknown, generation: number): void {
+ const response = value as Partial>;
+ if (
+ response.protocolVersion !== WORKER_PROTOCOL_VERSION ||
+ typeof response.requestId !== "number" ||
+ response.generation !== generation ||
+ !this.active ||
+ this.active.requestId !== response.requestId ||
+ this.active.generation !== generation
+ ) {
+ return;
+ }
+ const active = this.active;
+ this.active = null;
+ globalThis.clearTimeout(active.timeout);
+ if (response.ok === true && "payload" in response) {
+ active.resolve(response.payload as TResult);
+ return;
+ }
+ const details =
+ "error" in response && response.error
+ ? response.error
+ : { name: "Error", message: "Unknown worker failure" };
+ active.reject(
+ new WorkerRequestError(
+ "worker-error",
+ `${details.name}: ${details.message}`,
+ ),
+ );
+ }
+
+ private handleCrash(error: WorkerRequestError, generation: number): void {
+ if (generation !== this.generation) return;
+ this.restart(error);
+ }
+
+ private restart(error: WorkerRequestError): void {
+ if (this.active) {
+ globalThis.clearTimeout(this.active.timeout);
+ this.active.reject(error);
+ this.active = null;
+ }
+ if (this.worker) {
+ this.worker.onmessage = null;
+ this.worker.onerror = null;
+ this.worker.onmessageerror = null;
+ this.worker.terminate();
+ this.worker = null;
+ }
+ }
+}
diff --git a/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.test.ts b/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.test.ts
new file mode 100644
index 0000000..daa0873
--- /dev/null
+++ b/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.test.ts
@@ -0,0 +1,412 @@
+import { describe, expect, it } from "vitest";
+import {
+ executeEcmaScript,
+ replaceEcmaScript,
+} from "./EcmaScriptEngineAdapter";
+import type { RegexExecutionRequest } from "../../../model/match";
+import { DEFAULT_REGEX_LIMITS } from "../../request-limits";
+
+function request(
+ overrides: Partial = {},
+): RegexExecutionRequest {
+ return {
+ flavour: "ecmascript",
+ pattern: "(?\\p{Letter}+)",
+ flags: ["g", "u"],
+ subject: "Grüße 2026",
+ captureMetadata: [
+ {
+ number: 1,
+ name: "word",
+ range: { startUtf16: 0, endUtf16: 21 },
+ repeated: false,
+ },
+ ],
+ scanAll: false,
+ maximumMatches: 10_000,
+ maximumCaptureRows: 100_000,
+ ...overrides,
+ };
+}
+
+describe("native ECMAScript adapter", () => {
+ it("uses actual indices and identifies its runtime", () => {
+ const result = executeEcmaScript(request());
+
+ expect(result.accepted).toBe(true);
+ expect(result.engine.engineName).toBe("Native ECMAScript RegExp");
+ expect(result.engine.offsetUnit).toBe("utf16");
+ expect(result.matches).toHaveLength(1);
+ expect(result.matches[0]?.range).toEqual({
+ startUtf16: 0,
+ endUtf16: 5,
+ });
+ expect(result.matches[0]?.captures[0]).toEqual(
+ expect.objectContaining({
+ groupNumber: 1,
+ groupName: "word",
+ value: "Grüße",
+ status: "participated",
+ range: { startUtf16: 0, endUtf16: 5 },
+ }),
+ );
+ expect(result.flags.userFlags).toBe("gu");
+ expect(result.flags.effectiveFlags).toContain("d");
+ expect(result.flags.internallyAddedIndicesFlag).toBe(true);
+ });
+
+ it("distinguishes unmatched and matched-empty captures", () => {
+ const result = executeEcmaScript(
+ request({
+ pattern: "(?:(a)|())",
+ flags: [],
+ subject: "",
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 3 },
+ repeated: false,
+ },
+ {
+ number: 2,
+ range: { startUtf16: 4, endUtf16: 6 },
+ repeated: false,
+ },
+ ],
+ }),
+ );
+
+ expect(result.matches[0]?.captures[0]?.status).toBe("did-not-participate");
+ expect(result.matches[0]?.captures[1]).toEqual(
+ expect.objectContaining({
+ status: "matched-empty",
+ value: "",
+ range: { startUtf16: 0, endUtf16: 0 },
+ }),
+ );
+ });
+
+ it("preserves only the final retained repeated capture", () => {
+ const result = executeEcmaScript(
+ request({
+ pattern: "(a)+",
+ flags: [],
+ subject: "aaa",
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 3 },
+ repeated: true,
+ },
+ ],
+ }),
+ );
+ expect(result.matches[0]?.captures[0]).toEqual(
+ expect.objectContaining({
+ value: "a",
+ range: { startUtf16: 2, endUtf16: 3 },
+ }),
+ );
+ });
+
+ it("advances zero-length Unicode matches by code point", () => {
+ const result = executeEcmaScript(
+ request({
+ pattern: "(?=.)",
+ flags: ["g", "u"],
+ subject: "😀x",
+ captureMetadata: [],
+ }),
+ );
+ expect(result.matches.map((match) => match.range.startUtf16)).toEqual([
+ 0, 2,
+ ]);
+ });
+
+ it("honours sticky behavior and explicit scan-all behavior", () => {
+ const sticky = executeEcmaScript(
+ request({
+ pattern: "a",
+ flags: ["y"],
+ subject: "ba",
+ captureMetadata: [],
+ }),
+ );
+ expect(sticky.matches).toHaveLength(0);
+
+ const stickySingle = executeEcmaScript(
+ request({
+ pattern: "a",
+ flags: ["y"],
+ subject: "aa",
+ captureMetadata: [],
+ }),
+ );
+ expect(stickySingle.matches).toHaveLength(1);
+
+ const stickyScan = executeEcmaScript(
+ request({
+ pattern: "a",
+ flags: ["y"],
+ subject: "aa",
+ captureMetadata: [],
+ scanAll: true,
+ }),
+ );
+ expect(stickyScan.matches).toHaveLength(2);
+
+ const scan = executeEcmaScript(
+ request({
+ pattern: "a",
+ flags: [],
+ subject: "a a",
+ captureMetadata: [],
+ scanAll: true,
+ }),
+ );
+ expect(scan.matches).toHaveLength(2);
+ expect(scan.flags.userFlags).toBe("");
+ expect(scan.flags.internallyAddedGlobalFlag).toBe(true);
+ });
+
+ it("uses native replacement semantics and applies an output limit", () => {
+ const result = replaceEcmaScript({
+ ...request({
+ pattern: "(?\\w+)",
+ flags: ["g"],
+ subject: "alice bob",
+ }),
+ replacement: "[$]",
+ maximumOutputBytes: 13,
+ });
+ expect(result.output).toBe("[alice] [bob]");
+ expect(result.outputTruncated).toBe(false);
+ expect(result.truncated).toBe(false);
+
+ const limited = replaceEcmaScript({
+ ...request({ pattern: "a", flags: ["g"], subject: "aaaa" }),
+ replacement: "long",
+ maximumOutputBytes: 5,
+ });
+ expect(limited.outputTruncated).toBe(true);
+ expect(limited.truncated).toBe(true);
+ expect(limited.outputBytes).toBeLessThanOrEqual(5);
+ expect(limited.execution.diagnostics.at(-1)?.code).toBe(
+ "replacement-output-limit",
+ );
+ });
+
+ it("replaces every authoritative sticky match during explicit scan-all", () => {
+ const result = replaceEcmaScript({
+ ...request({
+ pattern: "a",
+ flags: ["y"],
+ subject: "aa",
+ captureMetadata: [],
+ scanAll: true,
+ }),
+ replacement: "x",
+ maximumOutputBytes: 100,
+ });
+
+ expect(result.execution.matches).toHaveLength(2);
+ expect(result.execution.matches.map((match) => match.range)).toEqual([
+ { startUtf16: 0, endUtf16: 1 },
+ { startUtf16: 1, endUtf16: 2 },
+ ]);
+ expect(result.execution.flags.userFlags).toBe("y");
+ expect(result.execution.flags.internallyAddedGlobalFlag).toBe(false);
+ expect(result.output).toBe("xx");
+ });
+
+ it.each([
+ {
+ name: "match count",
+ overrides: { maximumMatches: 1 },
+ },
+ {
+ name: "capture row count",
+ overrides: {
+ pattern: "(a)",
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 3 },
+ repeated: false,
+ },
+ ],
+ maximumCaptureRows: 1,
+ },
+ },
+ ])(
+ "replaces only returned matches when truncated by the $name limit",
+ ({ overrides }) => {
+ const result = replaceEcmaScript({
+ ...request({
+ pattern: "a",
+ flags: ["g"],
+ subject: "aa",
+ captureMetadata: [],
+ ...overrides,
+ }),
+ replacement: "x",
+ maximumOutputBytes: 100,
+ });
+
+ expect(result.execution.matches).toHaveLength(1);
+ expect(result.execution.truncated).toBe(true);
+ expect(result.outputTruncated).toBe(false);
+ expect(result.truncated).toBe(true);
+ expect(result.output).toBe("xa");
+ },
+ );
+
+ it("streams ECMAScript substitution tokens without constructing unbounded output", () => {
+ const subject = "abc";
+ const pattern = "(?b)";
+ const replacement = "$$|$&|$`|$'|$01|$1|$2|$9|$|$";
+ const result = replaceEcmaScript({
+ ...request({
+ pattern,
+ flags: [],
+ subject,
+ captureMetadata: [
+ {
+ number: 1,
+ name: "name",
+ range: { startUtf16: 0, endUtf16: pattern.length },
+ repeated: false,
+ },
+ ],
+ }),
+ replacement,
+ maximumOutputBytes: 1_000,
+ });
+ expect(result.output).toBe(
+ subject.replace(new RegExp(pattern), replacement),
+ );
+
+ const bounded = replaceEcmaScript({
+ ...request({ pattern: "a", flags: ["g"], subject: "aaaa" }),
+ replacement: "x".repeat(
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16,
+ ),
+ maximumOutputBytes: 5,
+ });
+ expect(bounded.output).toBe("xxxxx");
+ expect(bounded.outputBytes).toBe(5);
+ expect(bounded.truncated).toBe(true);
+ });
+
+ it("rejects a very large empty $& substitution before scanning it", () => {
+ const replacement = "$&".repeat(
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 / 2 + 1,
+ );
+ expect(() =>
+ replaceEcmaScript({
+ ...request({ pattern: "(?:)", flags: ["g"], subject: "" }),
+ replacement,
+ maximumOutputBytes: 5,
+ }),
+ ).toThrow(/Replacement template exceeds/u);
+ });
+
+ it("matches native replacement behavior across numbered and literal token edges", () => {
+ const subject = "ab a";
+ const pattern = "(a)(b)?";
+ for (const replacement of [
+ "$0",
+ "$00",
+ "$01",
+ "$02",
+ "$1",
+ "$2",
+ "$3",
+ "$10",
+ "$20",
+ "$99",
+ "$",
+ "$",
+ "$$",
+ "$&",
+ "$`",
+ "$'",
+ ]) {
+ const result = replaceEcmaScript({
+ ...request({
+ pattern,
+ flags: ["g"],
+ subject,
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 3 },
+ repeated: false,
+ },
+ {
+ number: 2,
+ range: { startUtf16: 3, endUtf16: 7 },
+ repeated: false,
+ },
+ ],
+ }),
+ replacement,
+ maximumOutputBytes: 10_000,
+ });
+ expect(result.output, replacement).toBe(
+ subject.replace(new RegExp(pattern, "g"), replacement),
+ );
+ }
+ });
+
+ it("returns authoritative native compile errors", () => {
+ const result = executeEcmaScript(
+ request({ pattern: "(", flags: [], captureMetadata: [] }),
+ );
+ expect(result.accepted).toBe(false);
+ expect(result.diagnostics[0]?.source).toBe("execution-engine");
+ expect(result.diagnostics[0]?.code).toBe("compile-error");
+ });
+
+ it("truncates match results explicitly", () => {
+ const result = executeEcmaScript(
+ request({
+ pattern: ".",
+ flags: ["g"],
+ subject: "abcd",
+ captureMetadata: [],
+ maximumMatches: 2,
+ }),
+ );
+ expect(result.matches).toHaveLength(2);
+ expect(result.truncated).toBe(true);
+ expect(result.diagnostics.at(-1)?.code).toBe("result-limit");
+ });
+
+ it("labels clipped display values while retaining exact ranges", () => {
+ const subject = "x".repeat(70_000);
+ const result = executeEcmaScript(
+ request({
+ pattern: "(x+)",
+ flags: [],
+ subject,
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 4 },
+ repeated: false,
+ },
+ ],
+ }),
+ );
+ expect(result.matches[0]).toMatchObject({
+ valueStatus: "truncated",
+ range: { startUtf16: 0, endUtf16: subject.length },
+ });
+ expect(result.matches[0]?.captures[0]).toMatchObject({
+ status: "truncated",
+ range: { startUtf16: 0, endUtf16: subject.length },
+ });
+ expect(result.diagnostics.at(-1)?.code).toBe("result-value-preview-limit");
+ });
+});
diff --git a/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.ts b/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.ts
new file mode 100644
index 0000000..001c753
--- /dev/null
+++ b/src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter.ts
@@ -0,0 +1,556 @@
+import type { RegexEngineAdapter } from "../../RegexEngineAdapter";
+import type {
+ CaptureResult,
+ EcmaScriptExecutionFlags,
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexMatchResult,
+ RegexReplacementRequest,
+ RegexReplacementResult,
+} from "../../../model/match";
+import type { RegexEngineInfo } from "../../../model/flavour";
+import type { RegexDiagnostic } from "../../../model/diagnostics";
+import { DEFAULT_REGEX_LIMITS } from "../../request-limits";
+
+const FLAG_ORDER = "dgimsuvy";
+const CAPTURE_VALUE_PREVIEW_UTF16 = 64 * 1024;
+const TOTAL_VALUE_PREVIEW_UTF16 = 8 * 1024 * 1024;
+
+class ValuePreviewBudget {
+ #remaining = TOTAL_VALUE_PREVIEW_UTF16;
+ truncated = false;
+
+ take(value: string): { readonly value: string; readonly truncated: boolean } {
+ const maximum = Math.min(CAPTURE_VALUE_PREVIEW_UTF16, this.#remaining);
+ const preview = value.slice(0, maximum);
+ this.#remaining -= preview.length;
+ const truncated = preview.length !== value.length;
+ this.truncated ||= truncated;
+ return { value: preview, truncated };
+ }
+}
+
+function hasIndicesSupport(): boolean {
+ try {
+ return new RegExp("", "d").hasIndices;
+ } catch {
+ return false;
+ }
+}
+
+function runtimeIdentity(): string {
+ return typeof navigator === "undefined"
+ ? "Unknown ECMAScript runtime"
+ : navigator.userAgent;
+}
+
+function engineInfo(): RegexEngineInfo {
+ const identity = runtimeIdentity();
+ return {
+ flavour: "ecmascript",
+ adapterVersion: "0.1.0",
+ engineName: "Native ECMAScript RegExp",
+ engineVersion: identity,
+ runtimeVersion: identity,
+ offsetUnit: "utf16",
+ capabilities: {
+ compilation: true,
+ matching: true,
+ replacement: true,
+ namedCaptures: true,
+ captureHistory: false,
+ actualTrace: false,
+ benchmark: false,
+ },
+ };
+}
+
+function normalizedFlags(
+ userFlags: readonly string[],
+ scanAll: boolean,
+): EcmaScriptExecutionFlags {
+ const unique = new Set(userFlags);
+ const user = FLAG_ORDER.split("")
+ .filter((flag) => unique.has(flag))
+ .join("");
+ const addIndices = hasIndicesSupport() && !unique.has("d");
+ const addGlobal = scanAll && !unique.has("g") && !unique.has("y");
+ if (addIndices) unique.add("d");
+ if (addGlobal) unique.add("g");
+ return {
+ userFlags: user,
+ effectiveFlags: FLAG_ORDER.split("")
+ .filter((flag) => unique.has(flag))
+ .join(""),
+ internallyAddedIndicesFlag: addIndices,
+ internallyAddedGlobalFlag: addGlobal,
+ };
+}
+
+function advanceStringIndex(
+ subject: string,
+ index: number,
+ unicode: boolean,
+): number {
+ if (!unicode) return index + 1;
+ const first = subject.charCodeAt(index);
+ if (first < 0xd800 || first > 0xdbff || index + 1 >= subject.length) {
+ return index + 1;
+ }
+ const second = subject.charCodeAt(index + 1);
+ return second >= 0xdc00 && second <= 0xdfff ? index + 2 : index + 1;
+}
+
+function captureResult(
+ match: RegExpExecArray,
+ groupNumber: number,
+ name: string | undefined,
+ previews: ValuePreviewBudget,
+): CaptureResult {
+ const value = match[groupNumber];
+ const indices = match.indices?.[groupNumber];
+ if (value === undefined) {
+ return {
+ groupNumber,
+ ...(name ? { groupName: name } : {}),
+ status: "did-not-participate",
+ };
+ }
+ const preview = previews.take(value);
+ if (!indices) {
+ return {
+ groupNumber,
+ ...(name ? { groupName: name } : {}),
+ value: preview.value,
+ status: preview.truncated
+ ? "truncated"
+ : value.length === 0
+ ? "matched-empty"
+ : "unavailable",
+ };
+ }
+ const [start, end] = indices;
+ return {
+ groupNumber,
+ ...(name ? { groupName: name } : {}),
+ value: preview.value,
+ status: preview.truncated
+ ? "truncated"
+ : value.length === 0
+ ? "matched-empty"
+ : "participated",
+ range: { startUtf16: start, endUtf16: end },
+ nativeRange: { start, end, unit: "utf16" },
+ };
+}
+
+function matchResult(
+ match: RegExpExecArray,
+ matchNumber: number,
+ request: RegexExecutionRequest,
+ previews: ValuePreviewBudget,
+): RegexMatchResult {
+ const indices = match.indices?.[0];
+ const start = indices?.[0] ?? match.index;
+ const end = indices?.[1] ?? match.index + match[0].length;
+ const names = new Map(
+ request.captureMetadata.map((capture) => [capture.number, capture.name]),
+ );
+ const preview = previews.take(match[0]);
+ const captures: CaptureResult[] = [];
+ for (let groupNumber = 1; groupNumber < match.length; groupNumber += 1) {
+ captures.push(
+ captureResult(match, groupNumber, names.get(groupNumber), previews),
+ );
+ }
+ return {
+ matchNumber,
+ value: preview.value,
+ valueStatus: preview.truncated ? "truncated" : "complete",
+ range: { startUtf16: start, endUtf16: end },
+ nativeRange: { start, end, unit: "utf16" },
+ captures,
+ };
+}
+
+function compileDiagnostic(error: unknown): RegexDiagnostic {
+ return {
+ id: "ecmascript-engine-compile-error",
+ source: "execution-engine",
+ severity: "error",
+ code: "compile-error",
+ message:
+ error instanceof Error
+ ? error.message
+ : "The native ECMAScript engine rejected the pattern.",
+ flavour: "ecmascript",
+ provenance: "reported",
+ };
+}
+
+function emptyExecution(
+ flags: EcmaScriptExecutionFlags,
+ start: number,
+ error: unknown,
+): RegexExecutionResult {
+ return {
+ accepted: false,
+ engine: engineInfo(),
+ flags,
+ matches: [],
+ diagnostics: [compileDiagnostic(error)],
+ elapsedMs: performance.now() - start,
+ truncated: false,
+ };
+}
+
+function executeEcmaScriptInternal(
+ request: RegexExecutionRequest,
+ onMatch?: (match: RegExpExecArray) => void,
+): RegexExecutionResult {
+ const start = performance.now();
+ const flags = normalizedFlags(request.flags, request.scanAll);
+ let expression: RegExp;
+ try {
+ expression = new RegExp(request.pattern, flags.effectiveFlags);
+ } catch (error) {
+ return emptyExecution(flags, start, error);
+ }
+ const iterate = flags.effectiveFlags.includes("g") || request.scanAll;
+ const unicode =
+ flags.effectiveFlags.includes("u") || flags.effectiveFlags.includes("v");
+ const matches: RegexMatchResult[] = [];
+ const previews = new ValuePreviewBudget();
+ let captureRows = 0;
+ let truncated = false;
+ expression.lastIndex = 0;
+
+ while (true) {
+ const match = expression.exec(request.subject);
+ if (!match) break;
+ const rowsForMatch = Math.max(1, match.length - 1);
+ if (
+ matches.length >= request.maximumMatches ||
+ captureRows + rowsForMatch > request.maximumCaptureRows
+ ) {
+ truncated = true;
+ break;
+ }
+ matches.push(matchResult(match, matches.length + 1, request, previews));
+ onMatch?.(match);
+ captureRows += rowsForMatch;
+ if (!iterate) break;
+ if (match[0].length === 0) {
+ expression.lastIndex = advanceStringIndex(
+ request.subject,
+ expression.lastIndex,
+ unicode,
+ );
+ if (expression.lastIndex > request.subject.length) break;
+ }
+ }
+
+ const diagnostics: RegexDiagnostic[] = [];
+ if (!hasIndicesSupport()) {
+ diagnostics.push({
+ id: "ecmascript-indices-unavailable",
+ source: "execution-engine",
+ severity: "warning",
+ code: "capture-indices-unavailable",
+ message:
+ "This browser does not support the ECMAScript indices flag; exact capture ranges are unavailable.",
+ flavour: "ecmascript",
+ provenance: "reported",
+ });
+ }
+ if (truncated) {
+ diagnostics.push({
+ id: "ecmascript-results-truncated",
+ source: "execution-engine",
+ severity: "warning",
+ code: "result-limit",
+ message: `Results reached the configured limit (${request.maximumMatches} matches or ${request.maximumCaptureRows} capture rows).`,
+ flavour: "ecmascript",
+ provenance: "derived",
+ });
+ }
+ if (previews.truncated) {
+ diagnostics.push({
+ id: "ecmascript-value-previews-truncated",
+ source: "execution-engine",
+ severity: "warning",
+ code: "result-value-preview-limit",
+ message: `Displayed result values were clipped to ${CAPTURE_VALUE_PREVIEW_UTF16.toLocaleString()} UTF-16 units each and ${TOTAL_VALUE_PREVIEW_UTF16.toLocaleString()} units overall. Exact engine ranges are retained.`,
+ flavour: "ecmascript",
+ provenance: "derived",
+ });
+ }
+ return {
+ accepted: true,
+ engine: engineInfo(),
+ flags,
+ matches,
+ diagnostics,
+ elapsedMs: performance.now() - start,
+ truncated,
+ };
+}
+
+export function executeEcmaScript(
+ request: RegexExecutionRequest,
+): RegexExecutionResult {
+ return executeEcmaScriptInternal(request);
+}
+
+function utf8Prefix(
+ value: string,
+ maximumBytes: number,
+): {
+ readonly value: string;
+ readonly bytes: number;
+ readonly complete: boolean;
+} {
+ let bytes = 0;
+ let index = 0;
+ while (index < value.length) {
+ const first = value.charCodeAt(index);
+ let width: number;
+ let units = 1;
+ if (first <= 0x7f) {
+ width = 1;
+ } else if (first <= 0x7ff) {
+ width = 2;
+ } else if (
+ first >= 0xd800 &&
+ first <= 0xdbff &&
+ index + 1 < value.length &&
+ value.charCodeAt(index + 1) >= 0xdc00 &&
+ value.charCodeAt(index + 1) <= 0xdfff
+ ) {
+ width = 4;
+ units = 2;
+ } else {
+ width = 3;
+ }
+ if (bytes + width > maximumBytes) break;
+ bytes += width;
+ index += units;
+ }
+ return {
+ value: value.slice(0, index),
+ bytes,
+ complete: index === value.length,
+ };
+}
+
+class BoundedUtf8Output {
+ readonly #maximumBytes: number;
+ readonly #chunks: string[] = [];
+ #bytes = 0;
+ #truncated = false;
+
+ constructor(maximumBytes: number) {
+ this.#maximumBytes = Math.max(0, Math.floor(maximumBytes));
+ }
+
+ append(value: string): boolean {
+ if (this.#truncated || value.length === 0) return !this.#truncated;
+ const prefix = utf8Prefix(value, this.#maximumBytes - this.#bytes);
+ if (prefix.value.length > 0) this.#chunks.push(prefix.value);
+ this.#bytes += prefix.bytes;
+ this.#truncated = !prefix.complete;
+ return !this.#truncated;
+ }
+
+ finish(): {
+ readonly value: string;
+ readonly bytes: number;
+ readonly truncated: boolean;
+ } {
+ return {
+ value: this.#chunks.join(""),
+ bytes: this.#bytes,
+ truncated: this.#truncated,
+ };
+ }
+}
+
+function* substitutionChunks(
+ replacement: string,
+ match: RegExpExecArray,
+ subject: string,
+): Generator {
+ const captureCount = match.length - 1;
+ const matchEnd = match.index + match[0].length;
+ let cursor = 0;
+ while (cursor < replacement.length) {
+ const dollar = replacement.indexOf("$", cursor);
+ if (dollar < 0) {
+ yield replacement.slice(cursor);
+ return;
+ }
+ if (dollar > cursor) yield replacement.slice(cursor, dollar);
+ const next = replacement[dollar + 1];
+ if (next === undefined) {
+ yield "$";
+ return;
+ }
+ if (next === "$") {
+ yield "$";
+ cursor = dollar + 2;
+ continue;
+ }
+ if (next === "&") {
+ yield match[0];
+ cursor = dollar + 2;
+ continue;
+ }
+ if (next === "`") {
+ yield subject.slice(0, match.index);
+ cursor = dollar + 2;
+ continue;
+ }
+ if (next === "'") {
+ yield subject.slice(matchEnd);
+ cursor = dollar + 2;
+ continue;
+ }
+ if (next === "<" && match.groups !== undefined) {
+ const close = replacement.indexOf(">", dollar + 2);
+ if (close >= 0) {
+ const name = replacement.slice(dollar + 2, close);
+ yield match.groups[name] ?? "";
+ cursor = close + 1;
+ continue;
+ }
+ }
+ if (/[0-9]/u.test(next)) {
+ const first = Number.parseInt(next, 10);
+ const following = replacement[dollar + 2];
+ const hasSecondDigit =
+ following !== undefined && /[0-9]/u.test(following);
+ const second = hasSecondDigit
+ ? Number.parseInt(`${next}${following}`, 10)
+ : 0;
+ if (hasSecondDigit && second > 0 && second <= captureCount) {
+ yield match[second] ?? "";
+ cursor = dollar + 3;
+ continue;
+ }
+ if (first > 0 && first <= captureCount) {
+ yield match[first] ?? "";
+ cursor = dollar + 2;
+ continue;
+ }
+ }
+ yield "$";
+ cursor = dollar + 1;
+ }
+}
+
+function appendOutputLimitDiagnostic(
+ execution: RegexExecutionResult,
+ truncated: boolean,
+ maximumOutputBytes: number,
+): RegexExecutionResult {
+ if (!truncated) return execution;
+ return {
+ ...execution,
+ diagnostics: [
+ ...execution.diagnostics,
+ {
+ id: "ecmascript-output-truncated",
+ source: "execution-engine",
+ severity: "warning",
+ code: "replacement-output-limit",
+ message: `Replacement preview was truncated at ${maximumOutputBytes} UTF-8 bytes.`,
+ flavour: "ecmascript",
+ provenance: "derived",
+ },
+ ],
+ };
+}
+
+function boundedReplacement(request: RegexReplacementRequest): {
+ readonly execution: RegexExecutionResult;
+ readonly value: string;
+ readonly bytes: number;
+ readonly truncated: boolean;
+} {
+ const output = new BoundedUtf8Output(request.maximumOutputBytes);
+ let nextSourcePosition = 0;
+ let outputStopped = false;
+ const execution = executeEcmaScriptInternal(request, (match) => {
+ if (outputStopped) return;
+ const matchEnd = match.index + match[0].length;
+ if (
+ !output.append(request.subject.slice(nextSourcePosition, match.index))
+ ) {
+ outputStopped = true;
+ return;
+ }
+ for (const chunk of substitutionChunks(
+ request.replacement,
+ match,
+ request.subject,
+ )) {
+ if (!output.append(chunk)) {
+ outputStopped = true;
+ return;
+ }
+ }
+ nextSourcePosition = matchEnd;
+ });
+ if (!outputStopped) {
+ output.append(request.subject.slice(nextSourcePosition));
+ }
+ return { execution, ...output.finish() };
+}
+
+export function replaceEcmaScript(
+ request: RegexReplacementRequest,
+): RegexReplacementResult {
+ if (
+ request.replacement.length >
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
+ ) {
+ throw new RangeError(
+ `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
+ );
+ }
+ const output = boundedReplacement(request);
+ const execution = appendOutputLimitDiagnostic(
+ output.execution,
+ output.truncated,
+ request.maximumOutputBytes,
+ );
+ return {
+ execution,
+ output: output.value,
+ outputBytes: output.bytes,
+ outputTruncated: output.truncated,
+ truncated: output.truncated || execution.truncated,
+ };
+}
+
+export class EcmaScriptEngineAdapter implements RegexEngineAdapter {
+ readonly flavour = "ecmascript" as const;
+
+ async load(): Promise {
+ return engineInfo();
+ }
+
+ async execute(request: RegexExecutionRequest): Promise {
+ return executeEcmaScript(request);
+ }
+
+ async replace(
+ request: RegexReplacementRequest,
+ ): Promise {
+ return replaceEcmaScript(request);
+ }
+
+ terminate(): void {
+ // Lifecycle termination is performed by killing the containing worker.
+ }
+}
diff --git a/src/regex/execution/offsets/codepoint-to-utf16.ts b/src/regex/execution/offsets/codepoint-to-utf16.ts
new file mode 100644
index 0000000..27411e0
--- /dev/null
+++ b/src/regex/execution/offsets/codepoint-to-utf16.ts
@@ -0,0 +1,49 @@
+export interface CodePointOffsetMap {
+ readonly codePointLength: number;
+ readonly utf16Length: number;
+ readonly codePointToUtf16: readonly number[];
+ readonly utf16ToCodePoint: ReadonlyMap;
+}
+
+export function buildCodePointOffsetMap(value: string): CodePointOffsetMap {
+ const codePointToUtf16 = [0];
+ const utf16ToCodePoint = new Map([[0, 0]]);
+ let codePointOffset = 0;
+ let utf16Offset = 0;
+ for (const character of value) {
+ codePointOffset += 1;
+ utf16Offset += character.length;
+ codePointToUtf16.push(utf16Offset);
+ utf16ToCodePoint.set(utf16Offset, codePointOffset);
+ }
+ return {
+ codePointLength: codePointOffset,
+ utf16Length: utf16Offset,
+ codePointToUtf16,
+ utf16ToCodePoint,
+ };
+}
+
+export function codePointToUtf16(
+ map: CodePointOffsetMap,
+ offset: number,
+): number {
+ const normalized = map.codePointToUtf16[offset];
+ if (normalized === undefined) {
+ throw new RangeError(`Code-point offset ${offset} is outside the input.`);
+ }
+ return normalized;
+}
+
+export function utf16ToCodePoint(
+ map: CodePointOffsetMap,
+ offset: number,
+): number {
+ const native = map.utf16ToCodePoint.get(offset);
+ if (native === undefined) {
+ throw new RangeError(
+ `UTF-16 offset ${offset} is outside the input or splits a surrogate pair.`,
+ );
+ }
+ return native;
+}
diff --git a/src/regex/execution/offsets/line-offset-map.test.ts b/src/regex/execution/offsets/line-offset-map.test.ts
new file mode 100644
index 0000000..12827c6
--- /dev/null
+++ b/src/regex/execution/offsets/line-offset-map.test.ts
@@ -0,0 +1,32 @@
+import { describe, expect, it } from "vitest";
+import { lineAndColumnsAt } from "./line-offset-map";
+
+describe("targeted line and column resolution", () => {
+ it("resolves sorted and duplicate offsets without retaining every line", () => {
+ const result = lineAndColumnsAt("a\n😀\nlast", [9, 2, 0, 5, 2]);
+
+ expect(result.size).toBe(4);
+ expect(result.get(0)).toEqual({ line: 1, column: 1 });
+ expect(result.get(2)).toEqual({ line: 2, column: 1 });
+ expect(result.get(5)).toEqual({ line: 3, column: 1 });
+ expect(result.get(9)).toEqual({ line: 3, column: 5 });
+ });
+
+ it("rejects invalid offsets", () => {
+ expect(() => lineAndColumnsAt("a", [-1])).toThrow(/outside/u);
+ expect(() => lineAndColumnsAt("a", [2])).toThrow(/outside/u);
+ });
+
+ it("recognizes every ECMAScript line terminator and treats CRLF as one", () => {
+ const source = "a\r\nb\rc\u2028d\u2029e";
+ const result = lineAndColumnsAt(source, [0, 2, 3, 5, 7, 9, 10]);
+
+ expect(result.get(0)).toEqual({ line: 1, column: 1 });
+ expect(result.get(2)).toEqual({ line: 1, column: 3 });
+ expect(result.get(3)).toEqual({ line: 2, column: 1 });
+ expect(result.get(5)).toEqual({ line: 3, column: 1 });
+ expect(result.get(7)).toEqual({ line: 4, column: 1 });
+ expect(result.get(9)).toEqual({ line: 5, column: 1 });
+ expect(result.get(10)).toEqual({ line: 5, column: 2 });
+ });
+});
diff --git a/src/regex/execution/offsets/line-offset-map.ts b/src/regex/execution/offsets/line-offset-map.ts
new file mode 100644
index 0000000..d3d9718
--- /dev/null
+++ b/src/regex/execution/offsets/line-offset-map.ts
@@ -0,0 +1,49 @@
+export interface LineAndColumn {
+ readonly line: number;
+ readonly column: number;
+}
+
+/**
+ * Resolves only requested offsets in one source scan. Memory therefore follows
+ * the bounded result set rather than the number of newlines in a large subject.
+ */
+export function lineAndColumnsAt(
+ source: string,
+ requestedOffsetsUtf16: readonly number[],
+): ReadonlyMap {
+ const offsets = [...new Set(requestedOffsetsUtf16)].sort(
+ (left, right) => left - right,
+ );
+ for (const offset of offsets) {
+ if (!Number.isSafeInteger(offset) || offset < 0 || offset > source.length) {
+ throw new RangeError(`UTF-16 offset ${offset} is outside the input.`);
+ }
+ }
+
+ const result = new Map();
+ let sourceOffset = 0;
+ let line = 1;
+ let lineStart = 0;
+ for (const target of offsets) {
+ while (sourceOffset < target) {
+ const current = source.charCodeAt(sourceOffset);
+ const isCrLfStart =
+ current === 13 && source.charCodeAt(sourceOffset + 1) === 10;
+ const isLineEnd =
+ current === 10 ||
+ (current === 13 && !isCrLfStart) ||
+ current === 0x2028 ||
+ current === 0x2029;
+ if (isLineEnd) {
+ line += 1;
+ lineStart = sourceOffset + 1;
+ }
+ sourceOffset += 1;
+ }
+ result.set(target, {
+ line,
+ column: target - lineStart + 1,
+ });
+ }
+ return result;
+}
diff --git a/src/regex/execution/offsets/offset-map.test.ts b/src/regex/execution/offsets/offset-map.test.ts
new file mode 100644
index 0000000..8ecbffb
--- /dev/null
+++ b/src/regex/execution/offsets/offset-map.test.ts
@@ -0,0 +1,66 @@
+import { describe, expect, it } from "vitest";
+import {
+ buildCodePointOffsetMap,
+ codePointToUtf16,
+ utf16ToCodePoint,
+} from "./codepoint-to-utf16";
+import {
+ buildUtf8OffsetMap,
+ utf16ToUtf8Byte,
+ utf8ByteToUtf16,
+} from "./utf8-to-utf16";
+import { buildEditorOffsetMap, lineAndColumnAt } from "./offset-map";
+
+describe("offset normalization", () => {
+ it.each([
+ ["", 0, 0],
+ ["ASCII", 5, 5],
+ ["é", 2, 1],
+ ["😀", 4, 2],
+ ["e\u0301", 3, 2],
+ ["👩💻", 11, 5],
+ ])("maps UTF-8 boundaries for %j", (value, bytes, utf16) => {
+ const map = buildUtf8OffsetMap(value);
+ expect(map.byteLength).toBe(bytes);
+ expect(map.utf16Length).toBe(utf16);
+ expect(utf8ByteToUtf16(map, bytes)).toBe(utf16);
+ expect(utf16ToUtf8Byte(map, utf16)).toBe(bytes);
+ });
+
+ it("rejects offsets inside encoded sequences or surrogate pairs", () => {
+ const map = buildUtf8OffsetMap("😀");
+ expect(() => utf8ByteToUtf16(map, 1)).toThrow(
+ /inside an encoded sequence/u,
+ );
+ expect(() => utf16ToUtf8Byte(map, 1)).toThrow(/splits a surrogate pair/u);
+ });
+
+ it("maps code points and UTF-16 boundaries", () => {
+ const map = buildCodePointOffsetMap("a😀b");
+ expect(map.codePointLength).toBe(3);
+ expect(codePointToUtf16(map, 2)).toBe(3);
+ expect(utf16ToCodePoint(map, 3)).toBe(2);
+ expect(() => utf16ToCodePoint(map, 2)).toThrow(/splits a surrogate pair/u);
+ });
+
+ it("tracks lines and end-of-input", () => {
+ const map = buildEditorOffsetMap("a\n😀\n");
+ expect(lineAndColumnAt(map, 0)).toEqual({ line: 1, column: 1 });
+ expect(lineAndColumnAt(map, 2)).toEqual({ line: 2, column: 1 });
+ expect(lineAndColumnAt(map, 5)).toEqual({ line: 3, column: 1 });
+ });
+
+ it("maps CRLF, bare CR and Unicode line separators", () => {
+ const map = buildEditorOffsetMap("a\r\nb\rc\u2028d\u2029e");
+
+ expect(lineAndColumnAt(map, 3)).toEqual({ line: 2, column: 1 });
+ expect(lineAndColumnAt(map, 5)).toEqual({ line: 3, column: 1 });
+ expect(lineAndColumnAt(map, 7)).toEqual({ line: 4, column: 1 });
+ expect(lineAndColumnAt(map, 9)).toEqual({ line: 5, column: 1 });
+ });
+
+ it("marks lone surrogates for future UTF-8 engine rejection", () => {
+ expect(buildUtf8OffsetMap("\ud800").containsLoneSurrogate).toBe(true);
+ expect(buildUtf8OffsetMap("😀").containsLoneSurrogate).toBe(false);
+ });
+});
diff --git a/src/regex/execution/offsets/offset-map.ts b/src/regex/execution/offsets/offset-map.ts
new file mode 100644
index 0000000..383c083
--- /dev/null
+++ b/src/regex/execution/offsets/offset-map.ts
@@ -0,0 +1,57 @@
+import {
+ buildCodePointOffsetMap,
+ type CodePointOffsetMap,
+} from "./codepoint-to-utf16";
+import { buildUtf8OffsetMap, type Utf8OffsetMap } from "./utf8-to-utf16";
+
+export interface EditorOffsetMap {
+ readonly source: string;
+ readonly utf8: Utf8OffsetMap;
+ readonly codePoints: CodePointOffsetMap;
+ readonly lineStartsUtf16: readonly number[];
+}
+
+export function buildEditorOffsetMap(source: string): EditorOffsetMap {
+ const lineStartsUtf16 = [0];
+ for (let index = 0; index < source.length; index += 1) {
+ const current = source.charCodeAt(index);
+ if (current === 13 && source.charCodeAt(index + 1) === 10) {
+ lineStartsUtf16.push(index + 2);
+ index += 1;
+ } else if (
+ current === 10 ||
+ current === 13 ||
+ current === 0x2028 ||
+ current === 0x2029
+ ) {
+ lineStartsUtf16.push(index + 1);
+ }
+ }
+ return {
+ source,
+ utf8: buildUtf8OffsetMap(source),
+ codePoints: buildCodePointOffsetMap(source),
+ lineStartsUtf16,
+ };
+}
+
+export function lineAndColumnAt(
+ map: EditorOffsetMap,
+ offsetUtf16: number,
+): { readonly line: number; readonly column: number } {
+ if (offsetUtf16 < 0 || offsetUtf16 > map.source.length) {
+ throw new RangeError(`UTF-16 offset ${offsetUtf16} is outside the input.`);
+ }
+ let low = 0;
+ let high = map.lineStartsUtf16.length - 1;
+ while (low < high) {
+ const middle = Math.ceil((low + high) / 2);
+ if ((map.lineStartsUtf16[middle] ?? 0) <= offsetUtf16) {
+ low = middle;
+ } else {
+ high = middle - 1;
+ }
+ }
+ const start = map.lineStartsUtf16[low] ?? 0;
+ return { line: low + 1, column: offsetUtf16 - start + 1 };
+}
diff --git a/src/regex/execution/offsets/utf8-to-utf16.ts b/src/regex/execution/offsets/utf8-to-utf16.ts
new file mode 100644
index 0000000..3c3539e
--- /dev/null
+++ b/src/regex/execution/offsets/utf8-to-utf16.ts
@@ -0,0 +1,62 @@
+export interface Utf8OffsetMap {
+ readonly byteLength: number;
+ readonly utf16Length: number;
+ readonly byteToUtf16: ReadonlyMap;
+ readonly utf16ToByte: ReadonlyMap;
+ readonly containsLoneSurrogate: boolean;
+}
+
+function utf8Length(codePoint: number): number {
+ if (codePoint <= 0x7f) return 1;
+ if (codePoint <= 0x7ff) return 2;
+ if (codePoint <= 0xffff) return 3;
+ return 4;
+}
+
+export function buildUtf8OffsetMap(value: string): Utf8OffsetMap {
+ const byteToUtf16 = new Map([[0, 0]]);
+ const utf16ToByte = new Map([[0, 0]]);
+ let byteOffset = 0;
+ let utf16Offset = 0;
+ let containsLoneSurrogate = false;
+ for (const character of value) {
+ const codePoint = character.codePointAt(0);
+ if (codePoint === undefined) continue;
+ if (codePoint >= 0xd800 && codePoint <= 0xdfff) {
+ containsLoneSurrogate = true;
+ }
+ const utf16Width = character.length;
+ const byteWidth = utf8Length(codePoint);
+ byteOffset += byteWidth;
+ utf16Offset += utf16Width;
+ byteToUtf16.set(byteOffset, utf16Offset);
+ utf16ToByte.set(utf16Offset, byteOffset);
+ }
+ return {
+ byteLength: byteOffset,
+ utf16Length: utf16Offset,
+ byteToUtf16,
+ utf16ToByte,
+ containsLoneSurrogate,
+ };
+}
+
+export function utf8ByteToUtf16(map: Utf8OffsetMap, offset: number): number {
+ const normalized = map.byteToUtf16.get(offset);
+ if (normalized === undefined) {
+ throw new RangeError(
+ `UTF-8 byte offset ${offset} is outside the input or inside an encoded sequence.`,
+ );
+ }
+ return normalized;
+}
+
+export function utf16ToUtf8Byte(map: Utf8OffsetMap, offset: number): number {
+ const native = map.utf16ToByte.get(offset);
+ if (native === undefined) {
+ throw new RangeError(
+ `UTF-16 offset ${offset} is outside the input or splits a surrogate pair.`,
+ );
+ }
+ return native;
+}
diff --git a/src/regex/execution/request-limits.test.ts b/src/regex/execution/request-limits.test.ts
new file mode 100644
index 0000000..baab120
--- /dev/null
+++ b/src/regex/execution/request-limits.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from "vitest";
+import { utf8ByteLength } from "./request-limits";
+
+describe("UTF-8 resource accounting", () => {
+ it("matches TextEncoder for ASCII, astral characters and lone surrogates", () => {
+ for (const value of ["", "ASCII", "Bérénice", "😀", "\ud800", "\udc00"]) {
+ expect(utf8ByteLength(value)).toBe(
+ new TextEncoder().encode(value).byteLength,
+ );
+ }
+ });
+});
diff --git a/src/regex/execution/request-limits.ts b/src/regex/execution/request-limits.ts
new file mode 100644
index 0000000..defcb14
--- /dev/null
+++ b/src/regex/execution/request-limits.ts
@@ -0,0 +1,84 @@
+export interface RegexResourceLimits {
+ readonly liveParseDebounceMs: number;
+ readonly liveExecutionDebounceMs: number;
+ readonly liveExecutionTimeoutMs: number;
+ readonly manualExecutionTimeoutMs: number;
+ readonly advancedMaximumTimeoutMs: number;
+ readonly patternSoftLengthUtf16: number;
+ readonly patternHardLengthUtf16: number;
+ readonly interactiveSubjectSoftBytes: number;
+ readonly interactiveSubjectHardBytes: number;
+ readonly corpusSoftBytes: number;
+ readonly corpusHardBytes: number;
+ readonly maximumReplacementTemplateUtf16: number;
+ readonly maximumListTemplateUtf16: number;
+ readonly maximumMatches: number;
+ readonly maximumCaptureGroups: number;
+ readonly maximumCaptureRows: number;
+ readonly maximumReplacementOutputBytes: number;
+ readonly maximumTraceEvents: number;
+ readonly maximumTraceBytes: number;
+ readonly maximumBenchmarkIterations: number;
+ readonly maximumBenchmarkWallTimeMs: number;
+ readonly maximumGeneratedCases: number;
+ readonly maximumMinimizerRuns: number;
+ readonly maximumLogBytes: number;
+}
+
+export const DEFAULT_REGEX_LIMITS: RegexResourceLimits = {
+ liveParseDebounceMs: 120,
+ liveExecutionDebounceMs: 220,
+ liveExecutionTimeoutMs: 250,
+ manualExecutionTimeoutMs: 2_000,
+ advancedMaximumTimeoutMs: 10_000,
+ patternSoftLengthUtf16: 16 * 1024,
+ patternHardLengthUtf16: 64 * 1024,
+ interactiveSubjectSoftBytes: 1024 * 1024,
+ interactiveSubjectHardBytes: 16 * 1024 * 1024,
+ corpusSoftBytes: 64 * 1024 * 1024,
+ corpusHardBytes: 256 * 1024 * 1024,
+ maximumReplacementTemplateUtf16: 64 * 1024,
+ maximumListTemplateUtf16: 16 * 1024,
+ maximumMatches: 10_000,
+ maximumCaptureGroups: 1_000,
+ maximumCaptureRows: 100_000,
+ maximumReplacementOutputBytes: 64 * 1024 * 1024,
+ maximumTraceEvents: 50_000,
+ maximumTraceBytes: 10 * 1024 * 1024,
+ maximumBenchmarkIterations: 10_000,
+ maximumBenchmarkWallTimeMs: 30_000,
+ maximumGeneratedCases: 1_000,
+ maximumMinimizerRuns: 2_000,
+ maximumLogBytes: 2 * 1024 * 1024,
+};
+
+export const TEMPLATE_PRESENTATION_LIMITS = {
+ replacementEditorMarks: 2_000,
+ replacementTokenRows: 500,
+ listTokenChips: 250,
+ listPreviewRows: 500,
+ replacementDiagnostics: 200,
+} as const;
+
+export function utf8ByteLength(value: string): number {
+ let bytes = 0;
+ for (let index = 0; index < value.length; index += 1) {
+ const first = value.charCodeAt(index);
+ if (first <= 0x7f) {
+ bytes += 1;
+ } else if (first <= 0x7ff) {
+ bytes += 2;
+ } else if (first >= 0xd800 && first <= 0xdbff) {
+ const second = value.charCodeAt(index + 1);
+ if (second >= 0xdc00 && second <= 0xdfff) {
+ bytes += 4;
+ index += 1;
+ } else {
+ bytes += 3;
+ }
+ } else {
+ bytes += 3;
+ }
+ }
+ return bytes;
+}
diff --git a/src/regex/execution/worker-protocol.ts b/src/regex/execution/worker-protocol.ts
new file mode 100644
index 0000000..7ab55e9
--- /dev/null
+++ b/src/regex/execution/worker-protocol.ts
@@ -0,0 +1,89 @@
+import type {
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementRequest,
+ RegexReplacementResult,
+} from "../model/match";
+import type {
+ RegexSyntaxRequest,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+} from "../model/syntax";
+import type { ReplacementSyntaxRequest } from "../syntax/SyntaxProvider";
+
+export const WORKER_PROTOCOL_VERSION = 1;
+
+export type SyntaxWorkerOperation =
+ | {
+ readonly kind: "parse-pattern";
+ readonly request: RegexSyntaxRequest;
+ }
+ | {
+ readonly kind: "parse-replacement";
+ readonly request: ReplacementSyntaxRequest;
+ };
+
+export type SyntaxWorkerResult =
+ | {
+ readonly kind: "parse-pattern";
+ readonly result: RegexSyntaxResult;
+ }
+ | {
+ readonly kind: "parse-replacement";
+ readonly result: ReplacementSyntaxResult;
+ };
+
+export type EngineWorkerOperation =
+ | {
+ readonly kind: "execute";
+ readonly request: RegexExecutionRequest;
+ }
+ | {
+ readonly kind: "replace";
+ readonly request: RegexReplacementRequest;
+ };
+
+export type EngineWorkerResult =
+ | {
+ readonly kind: "execute";
+ readonly result: RegexExecutionResult;
+ }
+ | {
+ readonly kind: "replace";
+ readonly result: RegexReplacementResult;
+ };
+
+export interface WorkerRequest {
+ readonly protocolVersion: typeof WORKER_PROTOCOL_VERSION;
+ readonly requestId: number;
+ readonly generation: number;
+ readonly payload: T;
+}
+
+export type WorkerResponse =
+ | {
+ readonly protocolVersion: typeof WORKER_PROTOCOL_VERSION;
+ readonly requestId: number;
+ readonly generation: number;
+ readonly ok: true;
+ readonly payload: T;
+ }
+ | {
+ readonly protocolVersion: typeof WORKER_PROTOCOL_VERSION;
+ readonly requestId: number;
+ readonly generation: number;
+ readonly ok: false;
+ readonly error: {
+ readonly name: string;
+ readonly message: string;
+ };
+ };
+
+export function workerError(error: unknown): {
+ readonly name: string;
+ readonly message: string;
+} {
+ return error instanceof Error
+ ? { name: error.name, message: error.message }
+ : { name: "Error", message: String(error) };
+}
diff --git a/src/regex/extraction/build-extraction-tree.ts b/src/regex/extraction/build-extraction-tree.ts
new file mode 100644
index 0000000..fa36172
--- /dev/null
+++ b/src/regex/extraction/build-extraction-tree.ts
@@ -0,0 +1,121 @@
+import type {
+ CaptureResult,
+ ExtractionNode,
+ RegexMatchResult,
+} from "../model/match";
+import type { CaptureDefinition } from "../model/syntax";
+
+function captureLabel(
+ result: CaptureResult,
+ definition: CaptureDefinition | undefined,
+): string {
+ const name = result.groupName ?? definition?.name;
+ return `Group ${result.groupNumber}${name ? ` — ${name}` : ""}`;
+}
+
+function buildCaptureNode(
+ result: CaptureResult,
+ definitions: ReadonlyMap,
+ childNumbers: ReadonlyMap,
+ results: ReadonlyMap,
+ matchNumber: number,
+): ExtractionNode {
+ const definition = definitions.get(result.groupNumber);
+ const children = (childNumbers.get(result.groupNumber) ?? [])
+ .map((number) => results.get(number))
+ .filter((capture): capture is CaptureResult => capture !== undefined)
+ .map((capture) =>
+ buildCaptureNode(
+ capture,
+ definitions,
+ childNumbers,
+ results,
+ matchNumber,
+ ),
+ );
+ return {
+ id: `match-${matchNumber}-capture-${result.groupNumber}`,
+ kind: "capture",
+ label: captureLabel(result, definition),
+ groupNumber: result.groupNumber,
+ ...((result.groupName ?? definition?.name)
+ ? { groupName: result.groupName ?? definition?.name }
+ : {}),
+ ...(result.value === undefined ? {} : { value: result.value }),
+ ...(result.range ? { range: result.range } : {}),
+ ...(result.nativeRange ? { nativeRange: result.nativeRange } : {}),
+ status: result.status,
+ children,
+ provenance: "engine-result",
+ };
+}
+
+export function buildExtractionTree(
+ matches: readonly RegexMatchResult[],
+ captures: readonly CaptureDefinition[],
+): readonly ExtractionNode[] {
+ const definitions = new Map(
+ captures.map((capture) => [capture.number, capture]),
+ );
+ const childNumbers = new Map();
+ const roots: number[] = [];
+ for (const capture of captures) {
+ if (capture.parentCaptureNumber === undefined) {
+ roots.push(capture.number);
+ continue;
+ }
+ const children = childNumbers.get(capture.parentCaptureNumber) ?? [];
+ children.push(capture.number);
+ childNumbers.set(capture.parentCaptureNumber, children);
+ }
+
+ return matches.map((match) => {
+ const results = new Map(
+ match.captures.map((capture) => [capture.groupNumber, capture]),
+ );
+ const fullMatch: ExtractionNode = {
+ id: `match-${match.matchNumber}-full`,
+ kind: "derived",
+ label: "Full match",
+ value: match.value,
+ range: match.range,
+ nativeRange: match.nativeRange,
+ status:
+ match.valueStatus === "truncated"
+ ? "truncated"
+ : match.value.length === 0
+ ? "matched-empty"
+ : "participated",
+ children: [],
+ provenance: "engine-result",
+ };
+ const captureNodes = roots
+ .map((number) => results.get(number))
+ .filter((capture): capture is CaptureResult => capture !== undefined)
+ .map((capture) =>
+ buildCaptureNode(
+ capture,
+ definitions,
+ childNumbers,
+ results,
+ match.matchNumber,
+ ),
+ );
+ return {
+ id: `match-${match.matchNumber}`,
+ kind: "match",
+ label: `Match ${match.matchNumber} — ${match.range.startUtf16}..${match.range.endUtf16}`,
+ value: match.value,
+ range: match.range,
+ nativeRange: match.nativeRange,
+ status:
+ match.valueStatus === "truncated"
+ ? "truncated"
+ : match.value.length === 0
+ ? "matched-empty"
+ : "participated",
+ children: [fullMatch, ...captureNodes],
+ provenance: "engine-result",
+ };
+ });
+}
diff --git a/src/regex/extraction/capture-rows.ts b/src/regex/extraction/capture-rows.ts
new file mode 100644
index 0000000..8102ac2
--- /dev/null
+++ b/src/regex/extraction/capture-rows.ts
@@ -0,0 +1,63 @@
+import type { CaptureResult, RegexMatchResult } from "../model/match";
+import { lineAndColumnsAt } from "../execution/offsets/line-offset-map";
+
+export interface CaptureRow {
+ readonly id: string;
+ readonly matchNumber: number;
+ readonly groupNumber: number;
+ readonly groupName?: string;
+ readonly status: CaptureResult["status"];
+ readonly value?: string;
+ readonly start?: number;
+ readonly end?: number;
+ readonly length?: number;
+ readonly nativeStart?: number;
+ readonly nativeEnd?: number;
+ readonly nativeUnit?: string;
+ readonly line?: number;
+ readonly column?: number;
+}
+
+export function buildCaptureRows(
+ matches: readonly RegexMatchResult[],
+ subject: string,
+): readonly CaptureRow[] {
+ const positions = lineAndColumnsAt(
+ subject,
+ matches.flatMap((match) =>
+ match.captures.flatMap((capture) =>
+ capture.range ? [capture.range.startUtf16] : [],
+ ),
+ ),
+ );
+ return matches.flatMap((match) =>
+ match.captures.map((capture) => {
+ const position = capture.range
+ ? positions.get(capture.range.startUtf16)
+ : undefined;
+ return {
+ id: `row-${match.matchNumber}-${capture.groupNumber}`,
+ matchNumber: match.matchNumber,
+ groupNumber: capture.groupNumber,
+ ...(capture.groupName ? { groupName: capture.groupName } : {}),
+ status: capture.status,
+ ...(capture.value === undefined ? {} : { value: capture.value }),
+ ...(capture.range
+ ? {
+ start: capture.range.startUtf16,
+ end: capture.range.endUtf16,
+ length: capture.range.endUtf16 - capture.range.startUtf16,
+ }
+ : {}),
+ ...(capture.nativeRange
+ ? {
+ nativeStart: capture.nativeRange.start,
+ nativeEnd: capture.nativeRange.end,
+ nativeUnit: capture.nativeRange.unit,
+ }
+ : {}),
+ ...(position ? position : {}),
+ };
+ }),
+ );
+}
diff --git a/src/regex/extraction/extraction.test.ts b/src/regex/extraction/extraction.test.ts
new file mode 100644
index 0000000..761428e
--- /dev/null
+++ b/src/regex/extraction/extraction.test.ts
@@ -0,0 +1,63 @@
+import { describe, expect, it } from "vitest";
+import { buildExtractionTree } from "./build-extraction-tree";
+import { buildCaptureRows } from "./capture-rows";
+
+describe("extraction normalization", () => {
+ it("uses syntactic hierarchy but preserves engine ranges and status", () => {
+ const matches = [
+ {
+ matchNumber: 1,
+ value: "ab",
+ valueStatus: "complete" as const,
+ range: { startUtf16: 2, endUtf16: 4 },
+ nativeRange: { start: 2, end: 4, unit: "utf16" as const },
+ captures: [
+ {
+ groupNumber: 1,
+ groupName: "outer",
+ value: "ab",
+ status: "participated" as const,
+ range: { startUtf16: 2, endUtf16: 4 },
+ nativeRange: { start: 2, end: 4, unit: "utf16" as const },
+ },
+ {
+ groupNumber: 2,
+ groupName: "inner",
+ value: "",
+ status: "matched-empty" as const,
+ range: { startUtf16: 4, endUtf16: 4 },
+ nativeRange: { start: 4, end: 4, unit: "utf16" as const },
+ },
+ ],
+ },
+ ];
+ const captures = [
+ {
+ number: 1,
+ name: "outer",
+ range: { startUtf16: 0, endUtf16: 8 },
+ repeated: false,
+ },
+ {
+ number: 2,
+ name: "inner",
+ range: { startUtf16: 3, endUtf16: 5 },
+ repeated: false,
+ parentCaptureNumber: 1,
+ },
+ ];
+ const tree = buildExtractionTree(matches, captures);
+ expect(tree[0]?.children[1]?.groupNumber).toBe(1);
+ expect(tree[0]?.children[1]?.children[0]).toEqual(
+ expect.objectContaining({
+ groupNumber: 2,
+ status: "matched-empty",
+ range: { startUtf16: 4, endUtf16: 4 },
+ provenance: "engine-result",
+ }),
+ );
+ expect(buildCaptureRows(matches, "x\nab")[0]).toEqual(
+ expect.objectContaining({ line: 2, column: 1, nativeUnit: "utf16" }),
+ );
+ });
+});
diff --git a/src/regex/list/list-template.test.ts b/src/regex/list/list-template.test.ts
new file mode 100644
index 0000000..5e9718e
--- /dev/null
+++ b/src/regex/list/list-template.test.ts
@@ -0,0 +1,257 @@
+import { describe, expect, it } from "vitest";
+import {
+ exportListRows,
+ MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS,
+ parseListTemplate,
+ renderListRows,
+} from "./list-template";
+import type { RegexMatchResult } from "../model/match";
+import { DEFAULT_REGEX_LIMITS } from "../execution/request-limits";
+
+const matches: readonly RegexMatchResult[] = [
+ {
+ matchNumber: 1,
+ value: "alice,42",
+ valueStatus: "complete",
+ range: { startUtf16: 4, endUtf16: 12 },
+ nativeRange: { start: 4, end: 12, unit: "utf16" },
+ captures: [
+ {
+ groupNumber: 1,
+ groupName: "name",
+ value: "alice",
+ status: "participated",
+ range: { startUtf16: 4, endUtf16: 9 },
+ nativeRange: { start: 4, end: 9, unit: "utf16" },
+ },
+ {
+ groupNumber: 2,
+ status: "did-not-participate",
+ },
+ ],
+ },
+];
+
+describe("typed list templates", () => {
+ it("renders non-executable match, capture and metadata tokens", () => {
+ const template = parseListTemplate(
+ "{{match}}:${name}:$2:$0:{{line}}:{{source}}",
+ );
+ const { rows } = renderListRows(
+ template,
+ matches,
+ "one\nalice,42",
+ "data.txt",
+ );
+ expect(rows[0]?.value).toBe("1:alice::alice,42:2:data.txt");
+ expect(rows[0]?.captures).toEqual({
+ "1": "alice",
+ name: "alice",
+ "2": null,
+ });
+ expect(rows[0]?.complete).toBe(true);
+ });
+
+ it("uses the participating capture for duplicate names", () => {
+ const duplicateNames: readonly RegexMatchResult[] = [
+ {
+ ...matches[0]!,
+ captures: [
+ {
+ groupNumber: 1,
+ groupName: "value",
+ value: "selected",
+ status: "participated",
+ range: { startUtf16: 4, endUtf16: 12 },
+ nativeRange: { start: 4, end: 12, unit: "utf16" },
+ },
+ {
+ groupNumber: 2,
+ groupName: "value",
+ status: "did-not-participate",
+ },
+ ],
+ },
+ ];
+
+ const { rows } = renderListRows(
+ parseListTemplate("${value}"),
+ duplicateNames,
+ "one\nselected",
+ );
+ expect(rows[0]?.value).toBe("selected");
+ expect(rows[0]?.captures).toEqual({
+ "1": "selected",
+ "2": null,
+ value: "selected",
+ });
+ });
+
+ it("supports capture 1000 and Unicode ECMAScript group names", () => {
+ const extended: readonly RegexMatchResult[] = [
+ {
+ ...matches[0]!,
+ captures: [
+ {
+ groupNumber: 1_000,
+ value: "last",
+ status: "participated",
+ range: { startUtf16: 4, endUtf16: 8 },
+ nativeRange: { start: 4, end: 8, unit: "utf16" },
+ },
+ {
+ groupNumber: 2,
+ groupName: "π",
+ value: "three",
+ status: "participated",
+ range: { startUtf16: 4, endUtf16: 9 },
+ nativeRange: { start: 4, end: 9, unit: "utf16" },
+ },
+ ],
+ },
+ ];
+
+ const template = parseListTemplate("$1000:${π}");
+ expect(template.tokens).toEqual([
+ { kind: "numbered-capture", number: 1_000 },
+ { kind: "literal", value: ":" },
+ { kind: "named-capture", name: "π" },
+ ]);
+ expect(
+ renderListRows(template, extended, "one\nlastthree").rows[0]?.value,
+ ).toBe("last:three");
+ });
+
+ it("quotes CSV correctly and preserves null capture state in JSON", () => {
+ const { rows } = renderListRows(
+ parseListTemplate("$0"),
+ matches,
+ "one\nalice,42",
+ );
+ expect(exportListRows(rows, "csv")).toContain('"alice,42"');
+ expect(
+ JSON.parse(exportListRows(rows, "json"))[0].captures["2"],
+ ).toBeNull();
+ expect(exportListRows(rows, "ndjson").trim().split("\n")).toHaveLength(1);
+ });
+
+ it("does not present clipped engine previews as exact export data", () => {
+ const exact = "x".repeat(70_000);
+ const { rows } = renderListRows(
+ parseListTemplate("$0:$1"),
+ [
+ {
+ matchNumber: 1,
+ value: exact.slice(0, 64 * 1024),
+ valueStatus: "truncated",
+ range: { startUtf16: 0, endUtf16: exact.length },
+ nativeRange: { start: 0, end: exact.length, unit: "utf16" },
+ captures: [
+ {
+ groupNumber: 1,
+ value: exact.slice(0, 64 * 1024),
+ status: "truncated",
+ range: { startUtf16: 0, endUtf16: exact.length },
+ nativeRange: { start: 0, end: exact.length, unit: "utf16" },
+ },
+ ],
+ },
+ ],
+ exact,
+ );
+ expect(rows[0]?.value).toBe(":");
+ expect(rows[0]?.complete).toBe(false);
+ expect(rows[0]?.captures).toEqual({ "1": null });
+ });
+
+ it("marks an export incomplete when only a clipped capture preview exists", () => {
+ const { rows } = renderListRows(
+ parseListTemplate("$1"),
+ [
+ {
+ matchNumber: 1,
+ value: "a",
+ valueStatus: "complete",
+ range: { startUtf16: 0, endUtf16: 1 },
+ nativeRange: { start: 0, end: 1, unit: "utf16" },
+ captures: [
+ {
+ groupNumber: 1,
+ value: "clipped",
+ status: "truncated",
+ },
+ ],
+ },
+ ],
+ "a",
+ );
+ expect(rows[0]).toEqual(
+ expect.objectContaining({
+ value: "",
+ complete: false,
+ captures: { "1": null },
+ }),
+ );
+ expect(rows[0]?.warnings[0]).toContain("clipped preview was not exported");
+ });
+
+ it("bounds each generated row before materializing its output", () => {
+ const rendered = renderListRows(
+ parseListTemplate("$0".repeat(8_192)),
+ [{ ...matches[0]!, value: "alice,420" }],
+ "one\nalice,420",
+ );
+ expect(rendered.rows[0]?.value).toHaveLength(64 * 1024);
+ expect(rendered.rows[0]?.complete).toBe(false);
+ expect(rendered.rows[0]?.warnings[0]).toContain("preview limit");
+ });
+
+ it("rejects oversized list templates without parsing or exporting a prefix", () => {
+ const source = "$0".repeat(
+ DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16 / 2 + 1,
+ );
+ const template = parseListTemplate(source);
+ const rendered = renderListRows(template, matches, "one\nalice,42");
+
+ expect(template.accepted).toBe(false);
+ expect(template.tokens).toEqual([]);
+ expect(rendered.templateAccepted).toBe(false);
+ expect(rendered.rows).toEqual([]);
+ expect(rendered.warnings[0]).toContain("was not parsed or rendered");
+ });
+
+ it("stops empty-token list evaluation at an explicit operation bound", () => {
+ const template = parseListTemplate("$99".repeat(2_000));
+ const requestedRows =
+ Math.floor(MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS / 2_000) + 1;
+ const rendered = renderListRows(
+ template,
+ Array.from({ length: requestedRows }, (_, index) => ({
+ ...matches[0]!,
+ matchNumber: index + 1,
+ })),
+ "one\nalice,42",
+ );
+
+ expect(template.accepted).toBe(true);
+ expect(rendered.truncated).toBe(true);
+ expect(rendered.rows).toHaveLength(requestedRows);
+ expect(rendered.rows.at(-1)?.complete).toBe(false);
+ expect(rendered.warnings[0]).toContain("token-operation limit");
+ });
+
+ it("refuses to treat an engine-limited match prefix as complete", () => {
+ const rendered = renderListRows(
+ parseListTemplate("$0"),
+ matches,
+ "one\nalice,42",
+ "test-text",
+ true,
+ );
+
+ expect(rendered.rows).toHaveLength(1);
+ expect(rendered.truncated).toBe(true);
+ expect(rendered.sourceResultTruncated).toBe(true);
+ expect(rendered.warnings[0]).toContain("engine stopped collecting matches");
+ });
+});
diff --git a/src/regex/list/list-template.ts b/src/regex/list/list-template.ts
new file mode 100644
index 0000000..18b9ef5
--- /dev/null
+++ b/src/regex/list/list-template.ts
@@ -0,0 +1,286 @@
+import type { RegexMatchResult } from "../model/match";
+import { lineAndColumnsAt } from "../execution/offsets/line-offset-map";
+import { DEFAULT_REGEX_LIMITS } from "../execution/request-limits";
+
+export type ListTemplateToken =
+ | { readonly kind: "literal"; readonly value: string }
+ | { readonly kind: "full-match" }
+ | { readonly kind: "numbered-capture"; readonly number: number }
+ | { readonly kind: "named-capture"; readonly name: string }
+ | { readonly kind: "match-number" }
+ | { readonly kind: "line-number" }
+ | { readonly kind: "source-name" };
+
+export interface ListTemplate {
+ readonly source: string;
+ readonly tokens: readonly ListTemplateToken[];
+ readonly accepted?: boolean;
+ readonly diagnostics?: readonly string[];
+}
+
+const TOKEN_PATTERN =
+ /\$0|\$(?:1000|[1-9]\d{0,2})(?!\d)|\$\{[$_\p{ID_Start}][$\u200c\u200d\p{ID_Continue}]*\}|\{\{(?:match|line|source)\}\}/gu;
+export const MAXIMUM_LIST_ROW_UTF16 = 64 * 1024;
+export const MAXIMUM_LIST_TOTAL_UTF16 = 8 * 1024 * 1024;
+export const MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS = 250_000;
+
+export function parseListTemplate(source: string): ListTemplate {
+ if (source.length > DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16) {
+ return {
+ source,
+ tokens: [],
+ accepted: false,
+ diagnostics: [
+ `List template exceeds the ${DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16.toLocaleString()} UTF-16 unit limit; it was not parsed or rendered.`,
+ ],
+ };
+ }
+ const tokens: ListTemplateToken[] = [];
+ let cursor = 0;
+ for (const match of source.matchAll(TOKEN_PATTERN)) {
+ const index = match.index;
+ if (index > cursor) {
+ tokens.push({ kind: "literal", value: source.slice(cursor, index) });
+ }
+ const raw = match[0];
+ if (raw === "$0") tokens.push({ kind: "full-match" });
+ else if (raw === "{{match}}") tokens.push({ kind: "match-number" });
+ else if (raw === "{{line}}") tokens.push({ kind: "line-number" });
+ else if (raw === "{{source}}") tokens.push({ kind: "source-name" });
+ else if (raw.startsWith("${")) {
+ tokens.push({ kind: "named-capture", name: raw.slice(2, -1) });
+ } else {
+ tokens.push({
+ kind: "numbered-capture",
+ number: Number.parseInt(raw.slice(1), 10),
+ });
+ }
+ cursor = index + raw.length;
+ }
+ if (cursor < source.length) {
+ tokens.push({ kind: "literal", value: source.slice(cursor) });
+ }
+ return { source, tokens, accepted: true, diagnostics: [] };
+}
+
+export interface ListRow {
+ readonly matchNumber: number;
+ readonly value: string;
+ readonly captures: Readonly>;
+ readonly complete: boolean;
+ readonly warnings: readonly string[];
+}
+
+export interface ListRenderResult {
+ readonly rows: readonly ListRow[];
+ readonly truncated: boolean;
+ readonly sourceResultTruncated: boolean;
+ readonly templateAccepted: boolean;
+ readonly warnings: readonly string[];
+}
+
+export function renderListRows(
+ template: ListTemplate,
+ matches: readonly RegexMatchResult[],
+ subject: string,
+ sourceName = "test-text",
+ sourceResultTruncated = false,
+): ListRenderResult {
+ if (
+ template.accepted === false ||
+ template.source.length > DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16
+ ) {
+ return {
+ rows: [],
+ truncated: matches.length > 0 || sourceResultTruncated,
+ sourceResultTruncated,
+ templateAccepted: false,
+ warnings: [
+ template.diagnostics?.[0] ??
+ `List template exceeds the ${DEFAULT_REGEX_LIMITS.maximumListTemplateUtf16.toLocaleString()} UTF-16 unit limit.`,
+ ],
+ };
+ }
+ const matchPositions = lineAndColumnsAt(
+ subject,
+ matches.map((match) => match.range.startUtf16),
+ );
+ const rows: ListRow[] = [];
+ let remainingTotal = MAXIMUM_LIST_TOTAL_UTF16;
+ let tokenEvaluations = 0;
+ let stoppedForEvaluationLimit = false;
+ for (const match of matches) {
+ if (remainingTotal === 0) break;
+ const warnings: string[] = [];
+ let complete = true;
+ const availableCaptureValue = (
+ capture: RegexMatchResult["captures"][number],
+ ): string | undefined => {
+ if (capture.status === "did-not-participate") return undefined;
+ if (capture.status === "truncated") {
+ complete = false;
+ warnings.push(
+ `Group ${capture.groupNumber} exceeded the value-preview limit; its clipped preview was not exported.`,
+ );
+ return undefined;
+ }
+ return capture.value;
+ };
+ const availableValues = new Map(
+ match.captures.map((capture) => [
+ capture.groupNumber,
+ availableCaptureValue(capture),
+ ]),
+ );
+ const namedCaptures = new Map<
+ string,
+ RegexMatchResult["captures"][number]
+ >();
+ for (const capture of match.captures) {
+ if (capture.groupName === undefined) continue;
+ const existing = namedCaptures.get(capture.groupName);
+ if (
+ existing === undefined ||
+ (existing.status === "did-not-participate" &&
+ capture.status !== "did-not-participate")
+ ) {
+ namedCaptures.set(capture.groupName, capture);
+ }
+ }
+ const byName = new Map(
+ [...namedCaptures].map(([name, capture]) => [
+ name,
+ availableValues.get(capture.groupNumber),
+ ]),
+ );
+ let remainingRow = Math.min(MAXIMUM_LIST_ROW_UTF16, remainingTotal);
+ const parts: string[] = [];
+ const append = (part: string) => {
+ const accepted = part.slice(0, remainingRow);
+ if (accepted.length > 0) parts.push(accepted);
+ remainingRow -= accepted.length;
+ remainingTotal -= accepted.length;
+ if (accepted.length !== part.length) {
+ complete = false;
+ warnings.push(
+ `Generated row exceeded the ${MAXIMUM_LIST_ROW_UTF16.toLocaleString()} UTF-16 unit preview limit.`,
+ );
+ }
+ };
+ for (const templateToken of template.tokens) {
+ if (tokenEvaluations >= MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS) {
+ complete = false;
+ stoppedForEvaluationLimit = true;
+ warnings.push(
+ `Template evaluation stopped at the ${MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS.toLocaleString()} token-operation limit.`,
+ );
+ break;
+ }
+ tokenEvaluations += 1;
+ if (remainingRow === 0) {
+ complete = false;
+ break;
+ }
+ switch (templateToken.kind) {
+ case "literal":
+ append(templateToken.value);
+ break;
+ case "full-match":
+ if (match.valueStatus === "truncated") {
+ complete = false;
+ warnings.push(
+ "The full match exceeded the value-preview limit; its clipped preview was not exported.",
+ );
+ } else {
+ append(match.value);
+ }
+ break;
+ case "numbered-capture":
+ append(availableValues.get(templateToken.number) ?? "");
+ break;
+ case "named-capture":
+ append(byName.get(templateToken.name) ?? "");
+ break;
+ case "match-number":
+ append(String(match.matchNumber));
+ break;
+ case "line-number":
+ append(
+ String(matchPositions.get(match.range.startUtf16)?.line ?? ""),
+ );
+ break;
+ case "source-name":
+ append(sourceName);
+ break;
+ }
+ }
+ const captures: Record = {};
+ for (const capture of match.captures) {
+ const value = availableValues.get(capture.groupNumber);
+ captures[String(capture.groupNumber)] = value ?? null;
+ }
+ for (const [name, capture] of namedCaptures) {
+ captures[name] = availableValues.get(capture.groupNumber) ?? null;
+ }
+ rows.push({
+ matchNumber: match.matchNumber,
+ value: parts.join(""),
+ captures,
+ complete,
+ warnings: [...new Set(warnings)],
+ });
+ if (stoppedForEvaluationLimit) break;
+ }
+ const truncated =
+ sourceResultTruncated ||
+ rows.length !== matches.length ||
+ stoppedForEvaluationLimit;
+ const warnings: string[] = [];
+ if (sourceResultTruncated) {
+ warnings.push(
+ "The engine stopped collecting matches at its configured result limit; generated rows cover only the returned prefix.",
+ );
+ }
+ if (stoppedForEvaluationLimit) {
+ warnings.push(
+ `List generation stopped at the ${MAXIMUM_LIST_RENDER_TOKEN_EVALUATIONS.toLocaleString()} token-operation limit.`,
+ );
+ } else if (rows.length !== matches.length) {
+ warnings.push(
+ `List generation stopped at the ${MAXIMUM_LIST_TOTAL_UTF16.toLocaleString()} UTF-16 unit materialization limit.`,
+ );
+ }
+ return {
+ rows,
+ truncated,
+ sourceResultTruncated,
+ templateAccepted: true,
+ warnings,
+ };
+}
+
+function csvCell(value: string | number): string {
+ const text = String(value);
+ return /[",\r\n]/u.test(text) ? `"${text.replaceAll('"', '""')}"` : text;
+}
+
+export function exportListRows(
+ rows: readonly ListRow[],
+ format: "text" | "csv" | "json" | "ndjson",
+): string {
+ switch (format) {
+ case "text":
+ return rows.map((row) => row.value).join("\n");
+ case "csv":
+ return [
+ "match,value",
+ ...rows.map(
+ (row) => `${csvCell(row.matchNumber)},${csvCell(row.value)}`,
+ ),
+ ].join("\r\n");
+ case "json":
+ return `${JSON.stringify(rows, null, 2)}\n`;
+ case "ndjson":
+ return `${rows.map((row) => JSON.stringify(row)).join("\n")}\n`;
+ }
+}
diff --git a/src/regex/model/diagnostics.ts b/src/regex/model/diagnostics.ts
new file mode 100644
index 0000000..a589100
--- /dev/null
+++ b/src/regex/model/diagnostics.ts
@@ -0,0 +1,14 @@
+import type { SourceRange } from "./syntax";
+
+export type DiagnosticSeverity = "error" | "warning" | "information";
+
+export interface RegexDiagnostic {
+ readonly id: string;
+ readonly source: "syntax-provider" | "execution-engine" | "analysis";
+ readonly severity: DiagnosticSeverity;
+ readonly code: string;
+ readonly message: string;
+ readonly range?: SourceRange;
+ readonly flavour: string;
+ readonly provenance: "reported" | "derived";
+}
diff --git a/src/regex/model/flavour.ts b/src/regex/model/flavour.ts
new file mode 100644
index 0000000..97bfdd4
--- /dev/null
+++ b/src/regex/model/flavour.ts
@@ -0,0 +1,45 @@
+export type RegexFlavourId =
+ | "ecmascript"
+ | "pcre2"
+ | "pcre"
+ | "python"
+ | "go"
+ | "rust"
+ | "dotnet"
+ | "java";
+
+export interface RegexEngineCapabilities {
+ readonly compilation: boolean;
+ readonly matching: boolean;
+ readonly replacement: boolean;
+ readonly namedCaptures: boolean;
+ readonly captureHistory: boolean;
+ readonly actualTrace: boolean;
+ readonly benchmark: boolean;
+}
+
+export interface RegexEngineInfo {
+ readonly flavour: RegexFlavourId;
+ readonly adapterVersion: string;
+ readonly engineName: string;
+ readonly engineVersion: string;
+ readonly runtimeVersion?: string;
+ readonly offsetUnit: "utf16" | "code-point" | "utf8-byte" | "byte";
+ readonly capabilities: RegexEngineCapabilities;
+}
+
+export interface FlavourCapability {
+ readonly flavour: RegexFlavourId;
+ readonly label: string;
+ readonly syntaxProvider: string;
+ readonly syntaxCoverage: "full-tested" | "partial" | "unavailable";
+ readonly executionEngine: string;
+ readonly engineVersion: string;
+ readonly execution: "full-tested" | "experimental" | "unavailable";
+ readonly replacement: boolean;
+ readonly namedCaptures: boolean;
+ readonly captureHistory: boolean;
+ readonly actualTrace: boolean;
+ readonly offsetModel: string;
+ readonly notes: readonly string[];
+}
diff --git a/src/regex/model/match.ts b/src/regex/model/match.ts
new file mode 100644
index 0000000..d207b88
--- /dev/null
+++ b/src/regex/model/match.ts
@@ -0,0 +1,99 @@
+import type { RegexDiagnostic } from "./diagnostics";
+import type { RegexEngineInfo, RegexFlavourId } from "./flavour";
+import type { CaptureDefinition, SourceRange } from "./syntax";
+
+export interface NativeTextRange {
+ readonly start: number;
+ readonly end: number;
+ readonly unit: "utf16" | "code-point" | "utf8-byte" | "byte";
+}
+
+export interface CaptureResult {
+ readonly groupNumber: number;
+ readonly groupName?: string;
+ readonly value?: string;
+ readonly status:
+ | "participated"
+ | "did-not-participate"
+ | "matched-empty"
+ | "truncated"
+ | "unavailable";
+ readonly range?: SourceRange;
+ readonly nativeRange?: NativeTextRange;
+}
+
+export interface RegexMatchResult {
+ readonly matchNumber: number;
+ readonly value: string;
+ readonly valueStatus: "complete" | "truncated";
+ readonly range: SourceRange;
+ readonly nativeRange: NativeTextRange;
+ readonly captures: readonly CaptureResult[];
+}
+
+export interface EcmaScriptExecutionFlags {
+ readonly userFlags: string;
+ readonly effectiveFlags: string;
+ readonly internallyAddedIndicesFlag: boolean;
+ readonly internallyAddedGlobalFlag: boolean;
+}
+
+export interface RegexExecutionRequest {
+ readonly flavour: RegexFlavourId;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly subject: string;
+ readonly captureMetadata: readonly CaptureDefinition[];
+ readonly scanAll: boolean;
+ readonly maximumMatches: number;
+ readonly maximumCaptureRows: number;
+}
+
+export interface RegexExecutionResult {
+ readonly accepted: boolean;
+ readonly engine: RegexEngineInfo;
+ readonly flags: EcmaScriptExecutionFlags;
+ readonly matches: readonly RegexMatchResult[];
+ readonly diagnostics: readonly RegexDiagnostic[];
+ readonly elapsedMs: number;
+ readonly truncated: boolean;
+}
+
+export interface RegexReplacementRequest extends RegexExecutionRequest {
+ readonly replacement: string;
+ readonly maximumOutputBytes: number;
+}
+
+export interface RegexReplacementResult {
+ readonly execution: RegexExecutionResult;
+ readonly output: string;
+ readonly outputBytes: number;
+ /** The materialized output stopped at maximumOutputBytes. */
+ readonly outputTruncated: boolean;
+ /** The output or its authoritative match collection is incomplete. */
+ readonly truncated: boolean;
+}
+
+export interface ExtractionNode {
+ readonly id: string;
+ readonly kind: "match" | "capture" | "capture-history" | "derived";
+ readonly label: string;
+ readonly groupNumber?: number;
+ readonly groupName?: string;
+ readonly value?: string;
+ readonly range?: SourceRange;
+ readonly nativeRange?: NativeTextRange;
+ readonly status:
+ | "participated"
+ | "did-not-participate"
+ | "matched-empty"
+ | "truncated"
+ | "unavailable";
+ readonly children: readonly ExtractionNode[];
+ readonly provenance:
+ | "engine-result"
+ | "engine-capture-history"
+ | "engine-trace"
+ | "derived-from-syntax"
+ | "inferred";
+}
diff --git a/src/regex/model/syntax.ts b/src/regex/model/syntax.ts
new file mode 100644
index 0000000..8889b02
--- /dev/null
+++ b/src/regex/model/syntax.ts
@@ -0,0 +1,157 @@
+import type { RegexDiagnostic } from "./diagnostics";
+import type { RegexFlavourId } from "./flavour";
+
+export interface SourceRange {
+ readonly startUtf16: number;
+ readonly endUtf16: number;
+}
+
+export type RegexNodeKind =
+ | "pattern"
+ | "disjunction"
+ | "alternative"
+ | "sequence"
+ | "literal"
+ | "escaped-literal"
+ | "dot"
+ | "character-class"
+ | "character-class-range"
+ | "unicode-property"
+ | "capture-group"
+ | "named-capture-group"
+ | "noncapture-group"
+ | "branch-reset-group"
+ | "atomic-group"
+ | "quantifier"
+ | "anchor"
+ | "word-boundary"
+ | "lookahead"
+ | "negative-lookahead"
+ | "lookbehind"
+ | "negative-lookbehind"
+ | "backreference"
+ | "subroutine-call"
+ | "recursion"
+ | "conditional"
+ | "inline-flags"
+ | "comment"
+ | "control-verb"
+ | "callout"
+ | "unsupported"
+ | "error-recovery";
+
+export interface NormalizedRegexNode {
+ readonly id: string;
+ readonly kind: RegexNodeKind;
+ readonly range: SourceRange;
+ readonly raw: string;
+ readonly explanation: string;
+ readonly children: readonly NormalizedRegexNode[];
+ readonly activeFlags?: readonly string[];
+ readonly capture?: {
+ readonly number: number;
+ readonly name?: string;
+ };
+ readonly quantifier?: {
+ readonly minimum: number;
+ readonly maximum: number | null;
+ readonly mode: "greedy" | "lazy" | "possessive";
+ };
+ readonly assertion?: {
+ readonly kind:
+ | "start"
+ | "end"
+ | "word-boundary"
+ | "non-word-boundary"
+ | "lookahead"
+ | "negative-lookahead"
+ | "lookbehind"
+ | "negative-lookbehind";
+ };
+ readonly properties: {
+ readonly zeroWidth: boolean;
+ readonly nullable: boolean | "unknown";
+ readonly minimumLength?: number;
+ readonly maximumLength?: number | null;
+ readonly consumesInput: boolean | "conditional";
+ };
+ readonly support: {
+ readonly flavour: RegexFlavourId;
+ readonly status: "supported" | "partial" | "unsupported" | "unknown";
+ readonly notes: readonly string[];
+ };
+ readonly provenance: {
+ readonly provider: string;
+ readonly providerVersion: string;
+ readonly source: "parsed" | "recovered" | "derived";
+ };
+}
+
+export interface NormalizedRegexToken {
+ readonly id: string;
+ readonly kind: RegexNodeKind;
+ readonly range: SourceRange;
+ readonly raw: string;
+ readonly captureNumber?: number;
+}
+
+export interface CaptureDefinition {
+ readonly number: number;
+ readonly name?: string;
+ readonly range: SourceRange;
+ readonly repeated: boolean;
+ readonly parentCaptureNumber?: number;
+}
+
+export interface RegexSyntaxRequest {
+ readonly flavour: RegexFlavourId;
+ readonly flavourVersion?: string;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly options: Readonly>;
+}
+
+export interface SyntaxCoverage {
+ readonly status: "full-tested" | "partial" | "unavailable";
+ readonly summary: string;
+ readonly unsupportedConstructs: readonly string[];
+}
+
+export interface RegexSyntaxResult {
+ readonly accepted: boolean;
+ readonly root: NormalizedRegexNode;
+ readonly tokens: readonly NormalizedRegexToken[];
+ readonly captures: readonly CaptureDefinition[];
+ readonly diagnostics: readonly RegexDiagnostic[];
+ readonly provider: {
+ readonly id: string;
+ readonly version: string;
+ };
+ readonly coverage: SyntaxCoverage;
+}
+
+export interface ReplacementToken {
+ readonly id: string;
+ readonly kind:
+ | "literal"
+ | "full-match"
+ | "prefix"
+ | "suffix"
+ | "numbered-capture"
+ | "named-capture"
+ | "escaped-dollar"
+ | "invalid";
+ readonly raw: string;
+ readonly range: SourceRange;
+ readonly explanation: string;
+ readonly captureNumber?: number;
+ readonly captureName?: string;
+}
+
+export interface ReplacementSyntaxResult {
+ readonly accepted?: boolean;
+ readonly tokens: readonly ReplacementToken[];
+ readonly diagnostics: readonly RegexDiagnostic[];
+ readonly totalDiagnostics?: number;
+ readonly diagnosticsTruncated?: boolean;
+}
diff --git a/src/regex/reference/token-reference.ts b/src/regex/reference/token-reference.ts
new file mode 100644
index 0000000..7c9a89e
--- /dev/null
+++ b/src/regex/reference/token-reference.ts
@@ -0,0 +1,142 @@
+import type { RegexFlavourId } from "../model/flavour";
+
+export interface ReferenceEntry {
+ readonly id: string;
+ readonly construct: string;
+ readonly syntax: string;
+ readonly explanation: string;
+ readonly flavours: readonly RegexFlavourId[];
+ readonly flags: readonly string[];
+ readonly example: string;
+ readonly caveat?: string;
+ readonly source: string;
+ readonly sourceUrl: string;
+}
+
+const ECMASCRIPT_REFERENCE_SOURCE =
+ "https://tc39.es/ecma262/multipage/text-processing.html#sec-regexp-regular-expression-objects";
+
+const ECMASCRIPT_REFERENCE_ENTRIES: readonly Omit<
+ ReferenceEntry,
+ "flavours" | "sourceUrl"
+>[] = [
+ {
+ id: "literal",
+ construct: "Literal",
+ syntax: "text",
+ explanation: "Matches the same sequence of characters.",
+ flags: [],
+ example: "invoice",
+ source: "ECMAScript RegExp grammar",
+ },
+ {
+ id: "any",
+ construct: "Any character",
+ syntax: ".",
+ explanation:
+ "Matches one character, excluding line terminators unless dotAll is active.",
+ flags: ["s"],
+ example: "a.b",
+ source: "ECMAScript Atom grammar",
+ },
+ {
+ id: "class",
+ construct: "Character class",
+ syntax: "[A-Z]",
+ explanation: "Matches one character represented by the class.",
+ flags: ["i", "u", "v"],
+ example: "[A-F0-9]",
+ source: "ECMAScript ClassRanges grammar",
+ },
+ {
+ id: "capture",
+ construct: "Capture group",
+ syntax: "(...)",
+ explanation: "Groups constructs and retains the matched substring.",
+ flags: [],
+ example: "(\\d{4})",
+ source: "ECMAScript Grouping grammar",
+ },
+ {
+ id: "named-capture",
+ construct: "Named capture",
+ syntax: "(?...)",
+ explanation:
+ "Retains the matched substring under a stable name and number.",
+ flags: [],
+ example: "(?\\d{4})",
+ source: "ECMAScript NamedCapturingGroup grammar",
+ },
+ {
+ id: "noncapture",
+ construct: "Non-capturing group",
+ syntax: "(?:...)",
+ explanation: "Groups constructs without creating a capture.",
+ flags: [],
+ example: "(?:https?|ftp)",
+ source: "ECMAScript GroupSpecifier grammar",
+ },
+ {
+ id: "quantifier",
+ construct: "Bounded repetition",
+ syntax: "{min,max}",
+ explanation: "Repeats the preceding construct between the selected bounds.",
+ flags: [],
+ example: "\\d{2,4}",
+ source: "ECMAScript QuantifierPrefix grammar",
+ },
+ {
+ id: "lookahead",
+ construct: "Lookahead",
+ syntax: "(?=...)",
+ explanation: "Requires a following match without consuming it.",
+ flags: [],
+ example: "\\d+(?= EUR)",
+ source: "ECMAScript Assertion grammar",
+ },
+ {
+ id: "lookbehind",
+ construct: "Lookbehind",
+ syntax: "(?<=...)",
+ explanation: "Requires a preceding match without consuming it.",
+ flags: [],
+ example: "(?<=€)\\d+",
+ source: "ECMAScript Assertion grammar",
+ },
+ {
+ id: "property",
+ construct: "Unicode property",
+ syntax: "\\p{Property}",
+ explanation: "Matches characters with the selected Unicode property.",
+ flags: ["u", "v"],
+ example: "\\p{Letter}+",
+ caveat: "Requires Unicode or Unicode-sets mode.",
+ source: "ECMAScript UnicodePropertyValueExpression grammar",
+ },
+ {
+ id: "backreference",
+ construct: "Named backreference",
+ syntax: "\\k",
+ explanation: "Matches the text retained by an earlier named capture.",
+ flags: [],
+ example: "(?\\w+)\\s+\\k",
+ source: "ECMAScript AtomEscape grammar",
+ },
+ {
+ id: "anchor",
+ construct: "Input anchors",
+ syntax: "^ $",
+ explanation:
+ "Assert the beginning or end of input; multiline mode also considers lines.",
+ flags: ["m"],
+ example: "^ERROR:.*$",
+ source: "ECMAScript Assertion grammar",
+ },
+];
+
+export const ECMASCRIPT_REFERENCE: readonly ReferenceEntry[] =
+ ECMASCRIPT_REFERENCE_ENTRIES.map((entry) => ({
+ ...entry,
+ flavours: ["ecmascript"],
+ sourceUrl: ECMASCRIPT_REFERENCE_SOURCE,
+ }));
diff --git a/src/regex/replacement/replacement-preview.test.ts b/src/regex/replacement/replacement-preview.test.ts
new file mode 100644
index 0000000..85210e6
--- /dev/null
+++ b/src/regex/replacement/replacement-preview.test.ts
@@ -0,0 +1,342 @@
+import { describe, expect, it } from "vitest";
+import { replaceEcmaScript } from "../execution/adapters/ecmascript/EcmaScriptEngineAdapter";
+import type { RegexReplacementResult } from "../model/match";
+import type {
+ CaptureDefinition,
+ ReplacementSyntaxResult,
+} from "../model/syntax";
+import { parseEcmaScriptReplacement } from "../syntax/providers/ecmascript/replacement";
+import {
+ buildReplacementPreview,
+ MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS,
+ MAXIMUM_REPLACEMENT_PREVIEW_MATCHES,
+ MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS,
+} from "./replacement-preview";
+
+const captures: readonly CaptureDefinition[] = [
+ {
+ number: 1,
+ name: "letter",
+ range: { startUtf16: 0, endUtf16: 12 },
+ repeated: false,
+ },
+];
+
+function result(
+ subject: string,
+ replacement = "[$]",
+ pattern = "(?[a-z])",
+): RegexReplacementResult {
+ return replaceEcmaScript({
+ flavour: "ecmascript",
+ pattern,
+ flags: ["g"],
+ subject,
+ replacement,
+ captureMetadata: captures,
+ scanAll: false,
+ maximumMatches: 10_000,
+ maximumCaptureRows: 100_000,
+ maximumOutputBytes: 64 * 1024 * 1024,
+ });
+}
+
+describe("buildReplacementPreview", () => {
+ it("maps each token to source and output ranges without executing code", () => {
+ const subject = "a b";
+ const syntax = parseEcmaScriptReplacement("[$]", captures);
+ const preview = buildReplacementPreview(syntax, result(subject), subject);
+
+ expect(preview.matches).toHaveLength(2);
+ expect(preview.matches[0]).toMatchObject({
+ matchNumber: 1,
+ originalRange: { startUtf16: 0, endUtf16: 1 },
+ originalPreview: "a",
+ replacementPreview: "[a]",
+ outputRange: { startUtf16: 0, endUtf16: 3 },
+ });
+ expect(preview.matches[1]).toMatchObject({
+ matchNumber: 2,
+ originalRange: { startUtf16: 2, endUtf16: 3 },
+ replacementPreview: "[b]",
+ outputRange: { startUtf16: 4, endUtf16: 7 },
+ });
+ const capture = preview.matches[0]?.contributions[1];
+ expect(capture).toMatchObject({
+ sourceRange: { startUtf16: 0, endUtf16: 1 },
+ outputRange: { startUtf16: 1, endUtf16: 2 },
+ valuePreview: "a",
+ valueLengthUtf16: 1,
+ });
+ });
+
+ it("reports unavailable output ranges when bounded output ends first", () => {
+ const subject = "a";
+ const syntax = parseEcmaScriptReplacement("$`$&$'", captures);
+ const boundedResult = {
+ ...result(subject, "$`$&$'"),
+ output: "",
+ outputBytes: 0,
+ outputTruncated: true,
+ truncated: true,
+ };
+ const preview = buildReplacementPreview(syntax, boundedResult, subject);
+
+ expect(preview.matches[0]?.outputRange).toBeUndefined();
+ expect(preview.matches[0]?.contributions[1]?.sourceRange).toEqual({
+ startUtf16: 0,
+ endUtf16: 1,
+ });
+ expect(preview.matches[0]?.contributions[1]?.outputRange).toBeUndefined();
+ });
+
+ it("uses complete capture values without indices but does not invent clipped lengths", () => {
+ const syntax = parseEcmaScriptReplacement("$1", captures);
+ const base = result("a", "$1", "(a)");
+ const unavailableIndices: RegexReplacementResult = {
+ ...base,
+ execution: {
+ ...base.execution,
+ matches: [
+ {
+ ...base.execution.matches[0]!,
+ captures: [
+ {
+ groupNumber: 1,
+ value: "a",
+ status: "unavailable",
+ },
+ ],
+ },
+ ],
+ },
+ output: "a",
+ outputBytes: 1,
+ };
+ const completePreview = buildReplacementPreview(
+ syntax,
+ unavailableIndices,
+ "a",
+ );
+ expect(completePreview.matches[0]?.contributions[0]).toMatchObject({
+ valuePreview: "a",
+ valueLengthUtf16: 1,
+ valueLengthExact: true,
+ outputRange: { startUtf16: 0, endUtf16: 1 },
+ });
+
+ const clippedCapture: RegexReplacementResult = {
+ ...unavailableIndices,
+ execution: {
+ ...unavailableIndices.execution,
+ matches: [
+ {
+ ...unavailableIndices.execution.matches[0]!,
+ captures: [
+ {
+ groupNumber: 1,
+ value: "a",
+ status: "truncated",
+ },
+ ],
+ },
+ ],
+ },
+ };
+ const clippedPreview = buildReplacementPreview(syntax, clippedCapture, "a");
+ expect(clippedPreview.matches[0]?.contributions[0]).toMatchObject({
+ valueLengthExact: false,
+ valueTruncated: true,
+ });
+ expect(
+ clippedPreview.matches[0]?.contributions[0]?.outputRange,
+ ).toBeUndefined();
+ expect(clippedPreview.matches[0]?.outputRange).toBeUndefined();
+ });
+
+ it("bounds match and contribution materialization explicitly", () => {
+ const engineMatches = Array.from(
+ { length: MAXIMUM_REPLACEMENT_PREVIEW_MATCHES + 1 },
+ (_, index) => ({
+ matchNumber: index + 1,
+ value: "a",
+ valueStatus: "complete" as const,
+ range: { startUtf16: index, endUtf16: index + 1 },
+ nativeRange: { start: index, end: index + 1, unit: "utf16" as const },
+ captures: [],
+ }),
+ );
+ const manyTokens: ReplacementSyntaxResult = {
+ accepted: true,
+ tokens: Array.from(
+ { length: MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1 },
+ (_, index) => ({
+ id: `token-${index}`,
+ kind: "literal" as const,
+ raw: "x",
+ range: { startUtf16: index, endUtf16: index + 1 },
+ explanation: "Insert x",
+ }),
+ ),
+ diagnostics: [],
+ totalDiagnostics: 0,
+ diagnosticsTruncated: false,
+ };
+ const boundedResult: RegexReplacementResult = {
+ execution: {
+ ...result("a").execution,
+ matches: engineMatches,
+ },
+ output: "x".repeat(MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1),
+ outputBytes: MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1,
+ outputTruncated: false,
+ truncated: false,
+ };
+ const preview = buildReplacementPreview(
+ manyTokens,
+ boundedResult,
+ "a".repeat(engineMatches.length),
+ );
+
+ expect(preview.matches).toHaveLength(
+ Math.floor(
+ MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS /
+ (MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS + 1),
+ ),
+ );
+ expect(preview.renderedContributions).toBe(
+ MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS,
+ );
+ expect(preview.truncated).toBe(true);
+ });
+
+ it("indexes captures once per visible match at the worst token-budget shape", () => {
+ const capturesPerMatch = 1_000;
+ const tokenCount = 16_384;
+ let indexedCaptureReads = 0;
+ const captureResults = Array.from(
+ { length: capturesPerMatch },
+ (_, index) => ({
+ groupNumber: index + 1,
+ groupName: index === capturesPerMatch - 1 ? "x" : `group-${index + 1}`,
+ value: "a",
+ status: "participated" as const,
+ range: { startUtf16: 0, endUtf16: 1 },
+ }),
+ );
+ const guardedCaptures = new Proxy(captureResults, {
+ get(target, property, receiver) {
+ if (property === "find") {
+ throw new Error("capture lookup must not scan once per token");
+ }
+ if (typeof property === "string" && /^\d+$/u.test(property)) {
+ indexedCaptureReads += 1;
+ }
+ return Reflect.get(target, property, receiver);
+ },
+ });
+ const tokens: ReplacementSyntaxResult["tokens"] = Array.from(
+ { length: tokenCount },
+ (_, index) => ({
+ id: `named-${index}`,
+ kind: "named-capture" as const,
+ raw: "$",
+ range: {
+ startUtf16: index * 4,
+ endUtf16: index * 4 + 4,
+ },
+ explanation: "Insert named capture x",
+ captureName: "x",
+ }),
+ );
+ const visibleMatchCount = Math.floor(
+ MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS / tokenCount,
+ );
+ const execution = result("a").execution;
+ const worstCase: RegexReplacementResult = {
+ execution: {
+ ...execution,
+ matches: Array.from({ length: visibleMatchCount }, (_, index) => ({
+ ...execution.matches[0]!,
+ matchNumber: index + 1,
+ captures: guardedCaptures,
+ })),
+ },
+ output: "a".repeat(tokenCount * visibleMatchCount),
+ outputBytes: tokenCount * visibleMatchCount,
+ outputTruncated: false,
+ truncated: false,
+ };
+ const preview = buildReplacementPreview(
+ {
+ accepted: true,
+ tokens,
+ diagnostics: [],
+ totalDiagnostics: 0,
+ diagnosticsTruncated: false,
+ },
+ worstCase,
+ "a",
+ );
+
+ expect(preview.matches).toHaveLength(visibleMatchCount);
+ expect(indexedCaptureReads).toBe(capturesPerMatch * visibleMatchCount);
+ });
+
+ it("prefers a participating duplicate name for (?a)|(?b) on b", () => {
+ const duplicateCaptures: readonly CaptureDefinition[] = [
+ {
+ number: 1,
+ name: "x",
+ range: { startUtf16: 0, endUtf16: 7 },
+ repeated: false,
+ },
+ {
+ number: 2,
+ name: "x",
+ range: { startUtf16: 8, endUtf16: 15 },
+ repeated: false,
+ },
+ ];
+ const syntax = parseEcmaScriptReplacement("$", duplicateCaptures);
+ const base = result("b");
+ const duplicateResult: RegexReplacementResult = {
+ ...base,
+ execution: {
+ ...base.execution,
+ matches: [
+ {
+ ...base.execution.matches[0]!,
+ value: "b",
+ captures: [
+ {
+ groupNumber: 1,
+ groupName: "x",
+ status: "did-not-participate",
+ },
+ {
+ groupNumber: 2,
+ groupName: "x",
+ value: "b",
+ status: "participated",
+ range: { startUtf16: 0, endUtf16: 1 },
+ },
+ ],
+ },
+ ],
+ },
+ output: "b",
+ outputBytes: 1,
+ outputTruncated: false,
+ truncated: false,
+ };
+
+ const preview = buildReplacementPreview(syntax, duplicateResult, "b");
+
+ expect(preview.matches[0]?.replacementPreview).toBe("b");
+ expect(preview.matches[0]?.contributions[0]).toMatchObject({
+ valuePreview: "b",
+ sourceRange: { startUtf16: 0, endUtf16: 1 },
+ });
+ });
+});
diff --git a/src/regex/replacement/replacement-preview.ts b/src/regex/replacement/replacement-preview.ts
new file mode 100644
index 0000000..791507c
--- /dev/null
+++ b/src/regex/replacement/replacement-preview.ts
@@ -0,0 +1,303 @@
+import type {
+ CaptureResult,
+ RegexMatchResult,
+ RegexReplacementResult,
+} from "../model/match";
+import type {
+ ReplacementSyntaxResult,
+ ReplacementToken,
+ SourceRange,
+} from "../model/syntax";
+
+export const MAXIMUM_REPLACEMENT_PREVIEW_MATCHES = 200;
+export const MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS = 2_000;
+export const MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS = 50_000;
+export const MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16 = 4 * 1024;
+export const MAXIMUM_REPLACEMENT_CONTRIBUTION_PREVIEW_UTF16 = 1_024;
+
+export interface ReplacementContributionPreview {
+ readonly id: string;
+ readonly matchNumber: number;
+ readonly token: ReplacementToken;
+ readonly valuePreview: string;
+ readonly valueLengthUtf16: number;
+ readonly valueLengthExact: boolean;
+ readonly valueTruncated: boolean;
+ readonly sourceRange?: SourceRange;
+ readonly outputRange?: SourceRange;
+}
+
+export interface ReplacementMatchPreview {
+ readonly id: string;
+ readonly matchNumber: number;
+ readonly originalRange: SourceRange;
+ readonly originalPreview: string;
+ readonly originalLengthUtf16: number;
+ readonly originalTruncated: boolean;
+ readonly replacementPreview: string;
+ readonly replacementLengthUtf16: number;
+ readonly replacementLengthExact: boolean;
+ readonly replacementTruncated: boolean;
+ readonly outputRange?: SourceRange;
+ readonly contributions: readonly ReplacementContributionPreview[];
+}
+
+export interface ReplacementPreview {
+ readonly matches: readonly ReplacementMatchPreview[];
+ readonly totalMatches: number;
+ readonly totalContributions: number;
+ readonly renderedContributions: number;
+ readonly truncated: boolean;
+}
+
+interface ContributionValue {
+ readonly lengthUtf16: number;
+ readonly lengthExact: boolean;
+ readonly preview: (maximumUtf16: number) => string;
+ readonly sourceRange?: SourceRange;
+}
+
+interface CaptureLookup {
+ readonly byNumber: ReadonlyMap;
+ readonly byName: ReadonlyMap;
+}
+
+function boundedSlice(
+ value: string,
+ start: number,
+ end: number,
+ maximumUtf16: number,
+): string {
+ if (maximumUtf16 <= 0 || end <= start) return "";
+ return value.slice(start, Math.min(end, start + maximumUtf16));
+}
+
+function indexCaptures(match: RegexMatchResult): CaptureLookup {
+ const byNumber = new Map();
+ const byName = new Map();
+ for (const capture of match.captures) {
+ byNumber.set(capture.groupNumber, capture);
+ if (capture.groupName === undefined) continue;
+ const existing = byName.get(capture.groupName);
+ if (
+ existing === undefined ||
+ (existing.status === "did-not-participate" &&
+ capture.status !== "did-not-participate")
+ ) {
+ byName.set(capture.groupName, capture);
+ }
+ }
+ return { byNumber, byName };
+}
+
+function captureForToken(
+ token: ReplacementToken,
+ captures: CaptureLookup,
+): CaptureResult | undefined {
+ if (token.captureNumber !== undefined) {
+ return captures.byNumber.get(token.captureNumber);
+ }
+ if (token.captureName !== undefined) {
+ return captures.byName.get(token.captureName);
+ }
+ return undefined;
+}
+
+function sourceContribution(
+ subject: string,
+ range: SourceRange,
+): ContributionValue {
+ return {
+ lengthUtf16: Math.max(0, range.endUtf16 - range.startUtf16),
+ lengthExact: true,
+ preview: (maximumUtf16) =>
+ boundedSlice(subject, range.startUtf16, range.endUtf16, maximumUtf16),
+ sourceRange: range,
+ };
+}
+
+function literalContribution(value: string): ContributionValue {
+ return {
+ lengthUtf16: value.length,
+ lengthExact: true,
+ preview: (maximumUtf16) => value.slice(0, maximumUtf16),
+ };
+}
+
+function contributionValue(
+ token: ReplacementToken,
+ match: RegexMatchResult,
+ captures: CaptureLookup,
+ subject: string,
+): ContributionValue {
+ switch (token.kind) {
+ case "literal":
+ case "invalid":
+ return literalContribution(token.raw);
+ case "escaped-dollar":
+ return literalContribution("$");
+ case "full-match":
+ return sourceContribution(subject, match.range);
+ case "prefix":
+ return sourceContribution(subject, {
+ startUtf16: 0,
+ endUtf16: match.range.startUtf16,
+ });
+ case "suffix":
+ return sourceContribution(subject, {
+ startUtf16: match.range.endUtf16,
+ endUtf16: subject.length,
+ });
+ case "numbered-capture":
+ case "named-capture": {
+ const capture = captureForToken(token, captures);
+ if (capture?.status === "did-not-participate") {
+ return literalContribution("");
+ }
+ if (capture?.range) return sourceContribution(subject, capture.range);
+ const fallback = literalContribution(capture?.value ?? "");
+ return capture?.status === "truncated"
+ ? { ...fallback, lengthExact: false }
+ : fallback;
+ }
+ }
+}
+
+function availableOutputRange(
+ startUtf16: number,
+ endUtf16: number,
+ outputLengthUtf16: number,
+): SourceRange | undefined {
+ return endUtf16 <= outputLengthUtf16 ? { startUtf16, endUtf16 } : undefined;
+}
+
+export function buildReplacementPreview(
+ syntax: ReplacementSyntaxResult,
+ result: RegexReplacementResult,
+ subject: string,
+): ReplacementPreview {
+ const engineMatches = result.execution.matches;
+ const maximumMatchesWithinEvaluationBudget =
+ syntax.tokens.length === 0
+ ? MAXIMUM_REPLACEMENT_PREVIEW_MATCHES
+ : Math.floor(
+ MAXIMUM_REPLACEMENT_PREVIEW_TOKEN_EVALUATIONS / syntax.tokens.length,
+ );
+ const visibleMatches = engineMatches.slice(
+ 0,
+ Math.min(
+ MAXIMUM_REPLACEMENT_PREVIEW_MATCHES,
+ maximumMatchesWithinEvaluationBudget,
+ ),
+ );
+ const matches: ReplacementMatchPreview[] = [];
+ let renderedContributions = 0;
+ let nextSourcePosition = 0;
+ let outputCursor = 0;
+ let outputPositionExact = true;
+
+ for (const match of visibleMatches) {
+ const captures = indexCaptures(match);
+ outputCursor += Math.max(0, match.range.startUtf16 - nextSourcePosition);
+ const outputStart = outputCursor;
+ const contributions: ReplacementContributionPreview[] = [];
+ const replacementParts: string[] = [];
+ let remainingReplacementPreview = MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16;
+ let replacementLengthUtf16 = 0;
+ let replacementLengthExact = true;
+
+ for (const token of syntax.tokens) {
+ const value = contributionValue(token, match, captures, subject);
+ const contributionStart = outputCursor + replacementLengthUtf16;
+ const contributionEnd = contributionStart + value.lengthUtf16;
+ const contributionRangeExact =
+ outputPositionExact && replacementLengthExact && value.lengthExact;
+ if (renderedContributions < MAXIMUM_REPLACEMENT_PREVIEW_CONTRIBUTIONS) {
+ const valuePreview = value.preview(
+ MAXIMUM_REPLACEMENT_CONTRIBUTION_PREVIEW_UTF16,
+ );
+ contributions.push({
+ id: `replacement-contribution-${match.matchNumber}-${token.id}`,
+ matchNumber: match.matchNumber,
+ token,
+ valuePreview,
+ valueLengthUtf16: value.lengthUtf16,
+ valueLengthExact: value.lengthExact,
+ valueTruncated:
+ !value.lengthExact || valuePreview.length < value.lengthUtf16,
+ ...(value.sourceRange ? { sourceRange: value.sourceRange } : {}),
+ ...(contributionRangeExact &&
+ availableOutputRange(
+ contributionStart,
+ contributionEnd,
+ result.output.length,
+ )
+ ? {
+ outputRange: {
+ startUtf16: contributionStart,
+ endUtf16: contributionEnd,
+ },
+ }
+ : {}),
+ });
+ renderedContributions += 1;
+ }
+ if (remainingReplacementPreview > 0) {
+ const preview = value.preview(remainingReplacementPreview);
+ replacementParts.push(preview);
+ remainingReplacementPreview -= preview.length;
+ }
+ replacementLengthUtf16 += value.lengthUtf16;
+ replacementLengthExact &&= value.lengthExact;
+ }
+
+ const outputEnd = outputStart + replacementLengthUtf16;
+ const originalLengthUtf16 = match.range.endUtf16 - match.range.startUtf16;
+ const originalPreview = boundedSlice(
+ subject,
+ match.range.startUtf16,
+ match.range.endUtf16,
+ MAXIMUM_REPLACEMENT_MATCH_PREVIEW_UTF16,
+ );
+ const replacementPreview = replacementParts.join("");
+ matches.push({
+ id: `replacement-match-${match.matchNumber}`,
+ matchNumber: match.matchNumber,
+ originalRange: match.range,
+ originalPreview,
+ originalLengthUtf16,
+ originalTruncated: originalPreview.length < originalLengthUtf16,
+ replacementPreview,
+ replacementLengthUtf16,
+ replacementLengthExact,
+ replacementTruncated:
+ !replacementLengthExact ||
+ replacementPreview.length < replacementLengthUtf16,
+ ...(outputPositionExact &&
+ replacementLengthExact &&
+ availableOutputRange(outputStart, outputEnd, result.output.length)
+ ? {
+ outputRange: {
+ startUtf16: outputStart,
+ endUtf16: outputEnd,
+ },
+ }
+ : {}),
+ contributions,
+ });
+ outputCursor = outputEnd;
+ outputPositionExact &&= replacementLengthExact;
+ nextSourcePosition = match.range.endUtf16;
+ }
+
+ const totalContributions = engineMatches.length * syntax.tokens.length;
+ return {
+ matches,
+ totalMatches: engineMatches.length,
+ totalContributions,
+ renderedContributions,
+ truncated:
+ matches.length < engineMatches.length ||
+ renderedContributions < totalContributions,
+ };
+}
diff --git a/src/regex/syntax/SyntaxProvider.ts b/src/regex/syntax/SyntaxProvider.ts
new file mode 100644
index 0000000..e5d994d
--- /dev/null
+++ b/src/regex/syntax/SyntaxProvider.ts
@@ -0,0 +1,25 @@
+import type {
+ CaptureDefinition,
+ RegexSyntaxRequest,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+ SyntaxCoverage,
+} from "../model/syntax";
+import type { RegexFlavourId } from "../model/flavour";
+
+export interface ReplacementSyntaxRequest {
+ readonly flavour: RegexFlavourId;
+ readonly replacement: string;
+ readonly captureMetadata: readonly CaptureDefinition[];
+}
+
+export interface RegexSyntaxProvider {
+ readonly id: string;
+ readonly version: string;
+ readonly supportedFlavours: readonly RegexFlavourId[];
+ parsePattern(request: RegexSyntaxRequest): Promise;
+ parseReplacement(
+ request: ReplacementSyntaxRequest,
+ ): Promise;
+ getCoverage(flavour: RegexFlavourId): SyntaxCoverage;
+}
diff --git a/src/regex/syntax/pattern-syntax-freshness.test.ts b/src/regex/syntax/pattern-syntax-freshness.test.ts
new file mode 100644
index 0000000..1c805d8
--- /dev/null
+++ b/src/regex/syntax/pattern-syntax-freshness.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from "vitest";
+import {
+ isPatternSyntaxCurrent,
+ isPatternSyntaxForInput,
+ type PatternSyntaxSnapshot,
+} from "./pattern-syntax-freshness";
+
+describe("pattern syntax freshness", () => {
+ const snapshot: PatternSyntaxSnapshot<{ readonly accepted: boolean }> = {
+ revision: 7,
+ pattern: "(?a)",
+ flags: ["g", "u"],
+ result: { accepted: true },
+ };
+
+ it("rejects accepted syntax as soon as the input revision changes", () => {
+ expect(
+ isPatternSyntaxCurrent(snapshot, 8, snapshot.pattern, snapshot.flags),
+ ).toBe(false);
+ expect(
+ isPatternSyntaxCurrent(snapshot, 7, snapshot.pattern, snapshot.flags),
+ ).toBe(true);
+ });
+
+ it("rejects syntax produced for a different pattern or flag set", () => {
+ expect(isPatternSyntaxForInput(snapshot, "(?b)", ["g", "u"])).toBe(
+ false,
+ );
+ expect(
+ isPatternSyntaxForInput(snapshot, snapshot.pattern, ["g", "i"]),
+ ).toBe(false);
+ expect(
+ isPatternSyntaxForInput(snapshot, snapshot.pattern, ["u", "g"]),
+ ).toBe(false);
+ });
+});
diff --git a/src/regex/syntax/pattern-syntax-freshness.ts b/src/regex/syntax/pattern-syntax-freshness.ts
new file mode 100644
index 0000000..36ea74b
--- /dev/null
+++ b/src/regex/syntax/pattern-syntax-freshness.ts
@@ -0,0 +1,31 @@
+export interface PatternSyntaxSnapshot {
+ readonly revision: number;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly result: TResult;
+}
+
+export function isPatternSyntaxForInput(
+ snapshot: PatternSyntaxSnapshot | undefined,
+ pattern: string,
+ flags: readonly string[],
+): snapshot is PatternSyntaxSnapshot {
+ return (
+ snapshot !== undefined &&
+ snapshot.pattern === pattern &&
+ snapshot.flags.length === flags.length &&
+ snapshot.flags.every((flag, index) => flag === flags[index])
+ );
+}
+
+export function isPatternSyntaxCurrent(
+ snapshot: PatternSyntaxSnapshot | undefined,
+ revision: number,
+ pattern: string,
+ flags: readonly string[],
+): snapshot is PatternSyntaxSnapshot {
+ return (
+ snapshot?.revision === revision &&
+ isPatternSyntaxForInput(snapshot, pattern, flags)
+ );
+}
diff --git a/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.test.ts b/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.test.ts
new file mode 100644
index 0000000..3ed6702
--- /dev/null
+++ b/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.test.ts
@@ -0,0 +1,280 @@
+import { describe, expect, it } from "vitest";
+import { EcmaScriptSyntaxProvider } from "./EcmaScriptSyntaxProvider";
+import {
+ DEFAULT_REGEX_LIMITS,
+ TEMPLATE_PRESENTATION_LIMITS,
+} from "../../../execution/request-limits";
+
+const provider = new EcmaScriptSyntaxProvider();
+
+describe("EcmaScriptSyntaxProvider", () => {
+ it("normalizes named captures, ranges, quantifiers and assertions", async () => {
+ const pattern = "^(?\\d{4})$";
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ flavourVersion: "2025",
+ pattern,
+ flags: ["u"],
+ options: {},
+ });
+
+ expect(result.accepted).toBe(true);
+ expect(result.provider).toEqual({
+ id: "regexpp-community",
+ version: "4.12.2",
+ });
+ expect(result.coverage.status).toBe("partial");
+ expect(result.root.range).toEqual({
+ startUtf16: 0,
+ endUtf16: pattern.length,
+ });
+ expect(result.captures).toEqual([
+ expect.objectContaining({
+ number: 1,
+ name: "date",
+ repeated: false,
+ }),
+ ]);
+ expect(result.tokens.map((token) => token.kind)).toEqual(
+ expect.arrayContaining([
+ "anchor",
+ "named-capture-group",
+ "quantifier",
+ "character-class",
+ ]),
+ );
+ const quantifier = result.tokens.find(
+ (token) => token.kind === "quantifier",
+ );
+ expect(quantifier?.raw).toBe("\\d{4}");
+ expect(result.root.properties.nullable).toBe(false);
+ expect(result.root.properties.minimumLength).toBe(4);
+ expect(result.root.properties.maximumLength).toBe(4);
+ });
+
+ it("derives variable lengths for Unicode string-set alternatives", async () => {
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "[\\q{ab|c}]",
+ flags: ["v"],
+ options: {},
+ });
+
+ expect(result.accepted).toBe(true);
+ expect(result.root.properties).toEqual(
+ expect.objectContaining({
+ nullable: false,
+ minimumLength: 1,
+ maximumLength: 2,
+ }),
+ );
+ });
+
+ it("does not invent exact lengths for Unicode properties of strings", async () => {
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "\\p{RGI_Emoji}",
+ flags: ["v"],
+ options: {},
+ });
+
+ expect(result.accepted).toBe(true);
+ expect(result.root.properties.minimumLength).toBeUndefined();
+ expect(result.root.properties.maximumLength).toBeUndefined();
+ expect(result.root.properties.nullable).toBe(false);
+ });
+
+ it("preserves the provider's exact error position without inventing a span", async () => {
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(?",
+ flags: [],
+ options: {},
+ });
+
+ expect(result.accepted).toBe(false);
+ expect(result.diagnostics).toHaveLength(1);
+ expect(result.diagnostics[0]?.range).toEqual({
+ startUtf16: 8,
+ endUtf16: 8,
+ });
+ expect(result.root.provenance.source).toBe("derived");
+ expect(result.root.support.notes).toContain(
+ "No partial provider AST is claimed.",
+ );
+ expect(result.root.children.at(-1)?.kind).toBe("error-recovery");
+ });
+
+ it("parses ECMAScript replacement tokens using native token semantics", async () => {
+ const result = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement: "Date: $; $01; $$; $&; $9",
+ captureMetadata: [
+ {
+ number: 1,
+ name: "date",
+ range: { startUtf16: 0, endUtf16: 4 },
+ repeated: false,
+ },
+ ],
+ });
+
+ expect(result.tokens.map((token) => token.kind)).toEqual(
+ expect.arrayContaining([
+ "named-capture",
+ "numbered-capture",
+ "escaped-dollar",
+ "full-match",
+ "literal",
+ ]),
+ );
+ expect(result.tokens).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ kind: "numbered-capture",
+ raw: "$01",
+ captureNumber: 1,
+ }),
+ expect.objectContaining({ kind: "literal", raw: "; $9" }),
+ ]),
+ );
+ expect(result.diagnostics).toEqual([]);
+ });
+
+ it("distinguishes literal and empty unknown named references", async () => {
+ const withoutNamedCaptures = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement: "$",
+ captureMetadata: [
+ {
+ number: 1,
+ range: { startUtf16: 0, endUtf16: 3 },
+ repeated: false,
+ },
+ ],
+ });
+ expect(withoutNamedCaptures.tokens).toEqual([
+ expect.objectContaining({ kind: "literal", raw: "$" }),
+ ]);
+ expect(withoutNamedCaptures.diagnostics).toEqual([]);
+
+ const withNamedCaptures = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement: "$",
+ captureMetadata: [
+ {
+ number: 1,
+ name: "known",
+ range: { startUtf16: 0, endUtf16: 10 },
+ repeated: false,
+ },
+ ],
+ });
+ expect(withNamedCaptures.tokens).toEqual([
+ expect.objectContaining({
+ kind: "named-capture",
+ raw: "$",
+ captureName: "missing",
+ }),
+ ]);
+ expect(withNamedCaptures.diagnostics[0]?.code).toBe(
+ "replacement-unknown-capture",
+ );
+ });
+
+ it("completely parses a maximum-sized $& template while bounding its UI diagnostics", async () => {
+ const replacement = "$&".repeat(
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 / 2,
+ );
+ const result = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement,
+ captureMetadata: [],
+ });
+
+ expect(result.accepted).toBe(true);
+ expect(result.tokens).toHaveLength(replacement.length / 2);
+ expect(new Set(result.tokens.map((token) => token.id)).size).toBe(
+ result.tokens.length,
+ );
+
+ const diagnostics = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement: "$".repeat(1_000),
+ captureMetadata: [
+ {
+ number: 1,
+ name: "known",
+ range: { startUtf16: 0, endUtf16: 1 },
+ repeated: false,
+ },
+ ],
+ });
+ expect(diagnostics.totalDiagnostics).toBe(1_000);
+ expect(diagnostics.diagnostics).toHaveLength(
+ TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics,
+ );
+ expect(diagnostics.diagnosticsTruncated).toBe(true);
+ expect(diagnostics.diagnostics.at(-1)?.code).toBe(
+ "replacement-diagnostic-limit",
+ );
+ });
+
+ it("rejects an oversized replacement before producing a partial token model", async () => {
+ const result = await provider.parseReplacement({
+ flavour: "ecmascript",
+ replacement: "$&".repeat(
+ DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16 / 2 + 1,
+ ),
+ captureMetadata: [],
+ });
+
+ expect(result.accepted).toBe(false);
+ expect(result.tokens).toEqual([]);
+ expect(result.diagnostics[0]?.code).toBe("replacement-template-limit");
+ });
+
+ it("distinguishes nested and repeated capture definitions", async () => {
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(?(?a)+)",
+ flags: [],
+ options: {},
+ });
+
+ expect(result.captures).toEqual([
+ expect.objectContaining({ number: 1, name: "outer", repeated: false }),
+ expect.objectContaining({
+ number: 2,
+ name: "inner",
+ repeated: true,
+ parentCaptureNumber: 1,
+ }),
+ ]);
+ });
+
+ it("records effective flags below inline modifier groups", async () => {
+ const result = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(?i:a)(?-i:b)",
+ flags: ["m"],
+ options: {},
+ });
+ const nodes = [result.root];
+ const literals = [];
+ while (nodes.length > 0) {
+ const node = nodes.pop();
+ if (!node) continue;
+ if (node.kind === "literal") literals.push(node);
+ nodes.push(...node.children);
+ }
+
+ expect(literals.find((node) => node.raw === "a")?.activeFlags).toEqual([
+ "i",
+ "m",
+ ]);
+ expect(literals.find((node) => node.raw === "b")?.activeFlags).toEqual([
+ "m",
+ ]);
+ });
+});
diff --git a/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.ts b/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.ts
new file mode 100644
index 0000000..01252c4
--- /dev/null
+++ b/src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider.ts
@@ -0,0 +1,119 @@
+import { RegExpParser, RegExpSyntaxError } from "@eslint-community/regexpp";
+import type {
+ RegexSyntaxRequest,
+ RegexSyntaxResult,
+ ReplacementSyntaxResult,
+ SyntaxCoverage,
+} from "../../../model/syntax";
+import type { RegexFlavourId } from "../../../model/flavour";
+import type {
+ RegexSyntaxProvider,
+ ReplacementSyntaxRequest,
+} from "../../SyntaxProvider";
+import { emptyRecoveredPattern, normalizePattern } from "./normalize";
+import { parseEcmaScriptReplacement } from "./replacement";
+
+const parser = new RegExpParser({ ecmaVersion: 2025 });
+const coverage: SyntaxCoverage = {
+ status: "partial",
+ summary:
+ "Complete parsing through the regexpp 4.12.2 ECMAScript 2025 grammar; application-owned normalized explanations are tested for the current feature matrix and remain partial for advanced constructs.",
+ unsupportedConstructs: [
+ "tolerant recovery for malformed patterns",
+ "exact consumed-length bounds for Unicode properties of strings",
+ ],
+};
+
+function flagsText(request: RegexSyntaxRequest): string {
+ return [...new Set(request.flags)].join("");
+}
+
+export class EcmaScriptSyntaxProvider implements RegexSyntaxProvider {
+ readonly id = "regexpp-community";
+ readonly version = "4.12.2";
+ readonly supportedFlavours = ["ecmascript"] as const;
+
+ async parsePattern(request: RegexSyntaxRequest): Promise {
+ if (request.flavour !== "ecmascript") {
+ throw new Error(`Unsupported syntax flavour: ${request.flavour}`);
+ }
+ const flags = flagsText(request);
+ try {
+ const parsedFlags = parser.parseFlags(flags);
+ const pattern = parser.parsePattern(
+ request.pattern,
+ 0,
+ request.pattern.length,
+ {
+ unicode: parsedFlags.unicode,
+ unicodeSets: parsedFlags.unicodeSets,
+ },
+ );
+ const normalized = normalizePattern(pattern, request.flags);
+ return {
+ accepted: true,
+ ...normalized,
+ diagnostics: [],
+ provider: { id: this.id, version: this.version },
+ coverage,
+ };
+ } catch (error) {
+ const index =
+ error instanceof RegExpSyntaxError
+ ? Math.max(0, Math.min(request.pattern.length, error.index))
+ : 0;
+ const range = {
+ startUtf16: index,
+ endUtf16: index,
+ };
+ const message =
+ error instanceof Error ? error.message : "Invalid ECMAScript pattern";
+ const normalized = emptyRecoveredPattern(
+ request.pattern,
+ range,
+ message,
+ request.flags,
+ );
+ return {
+ accepted: false,
+ ...normalized,
+ diagnostics: [
+ {
+ id: "ecmascript-syntax-error",
+ source: "syntax-provider",
+ severity: "error",
+ code: "invalid-pattern",
+ message,
+ range,
+ flavour: "ecmascript",
+ provenance: "reported",
+ },
+ ],
+ provider: { id: this.id, version: this.version },
+ coverage,
+ };
+ }
+ }
+
+ async parseReplacement(
+ request: ReplacementSyntaxRequest,
+ ): Promise {
+ if (request.flavour !== "ecmascript") {
+ throw new Error(`Unsupported replacement flavour: ${request.flavour}`);
+ }
+ return parseEcmaScriptReplacement(
+ request.replacement,
+ request.captureMetadata,
+ );
+ }
+
+ getCoverage(flavour: RegexFlavourId): SyntaxCoverage {
+ return flavour === "ecmascript"
+ ? coverage
+ : {
+ status: "unavailable",
+ summary: `The community ECMAScript provider does not parse ${flavour}.`,
+ unsupportedConstructs: ["all"],
+ };
+ }
+}
diff --git a/src/regex/syntax/providers/ecmascript/normalize.ts b/src/regex/syntax/providers/ecmascript/normalize.ts
new file mode 100644
index 0000000..aa70acb
--- /dev/null
+++ b/src/regex/syntax/providers/ecmascript/normalize.ts
@@ -0,0 +1,678 @@
+import type { AST } from "@eslint-community/regexpp";
+import type {
+ CaptureDefinition,
+ NormalizedRegexNode,
+ NormalizedRegexToken,
+ RegexNodeKind,
+ SourceRange,
+} from "../../../model/syntax";
+
+const PROVIDER = "regexpp-community";
+const PROVIDER_VERSION = "4.12.2";
+
+interface NormalizationState {
+ nextNodeId: number;
+ nextCaptureNumber: number;
+ readonly captures: CaptureDefinition[];
+}
+
+interface NodeContext {
+ readonly activeCaptureNumber?: number;
+ readonly repeated: boolean;
+ readonly activeFlags: readonly string[];
+}
+
+const FLAG_ORDER = ["d", "g", "i", "m", "s", "u", "v", "y"] as const;
+
+function flagsWithModifiers(
+ flags: readonly string[],
+ modifiers: AST.Modifiers,
+): readonly string[] {
+ const active = new Set(flags);
+ const update = (modifierFlags: AST.ModifierFlags, enabled: boolean) => {
+ if (modifierFlags.ignoreCase) {
+ if (enabled) active.add("i");
+ else active.delete("i");
+ }
+ if (modifierFlags.multiline) {
+ if (enabled) active.add("m");
+ else active.delete("m");
+ }
+ if (modifierFlags.dotAll) {
+ if (enabled) active.add("s");
+ else active.delete("s");
+ }
+ };
+ update(modifiers.add, true);
+ if (modifiers.remove) update(modifiers.remove, false);
+ return FLAG_ORDER.filter((flag) => active.has(flag));
+}
+
+function sourceRange(node: AST.Node): SourceRange {
+ return { startUtf16: node.start, endUtf16: node.end };
+}
+
+function nodeKind(node: AST.Node): RegexNodeKind {
+ switch (node.type) {
+ case "Pattern":
+ return node.alternatives.length > 1 ? "disjunction" : "pattern";
+ case "Alternative":
+ return node.elements.length > 1 ? "sequence" : "alternative";
+ case "Group":
+ return node.modifiers ? "inline-flags" : "noncapture-group";
+ case "CapturingGroup":
+ return node.name ? "named-capture-group" : "capture-group";
+ case "Quantifier":
+ return "quantifier";
+ case "Assertion":
+ if (node.kind === "start" || node.kind === "end") return "anchor";
+ if (node.kind === "word") return "word-boundary";
+ if (node.kind === "lookahead") {
+ return node.negate ? "negative-lookahead" : "lookahead";
+ }
+ return "negate" in node && node.negate
+ ? "negative-lookbehind"
+ : "lookbehind";
+ case "Character":
+ return node.raw.startsWith("\\") ? "escaped-literal" : "literal";
+ case "CharacterSet":
+ if (node.kind === "any") return "dot";
+ return node.kind === "property" ? "unicode-property" : "character-class";
+ case "CharacterClass":
+ case "ExpressionCharacterClass":
+ case "ClassIntersection":
+ case "ClassSubtraction":
+ case "ClassStringDisjunction":
+ case "StringAlternative":
+ return "character-class";
+ case "CharacterClassRange":
+ return "character-class-range";
+ case "Backreference":
+ return "backreference";
+ case "Modifiers":
+ case "ModifierFlags":
+ return "inline-flags";
+ case "RegExpLiteral":
+ case "Flags":
+ return "pattern";
+ }
+}
+
+function childrenOf(node: AST.Node): readonly AST.Node[] {
+ switch (node.type) {
+ case "RegExpLiteral":
+ return [node.pattern, node.flags];
+ case "Pattern":
+ case "CapturingGroup":
+ case "Group":
+ return node.alternatives;
+ case "Alternative":
+ return node.elements;
+ case "Quantifier":
+ return [node.element];
+ case "Assertion":
+ return "alternatives" in node ? node.alternatives : [];
+ case "CharacterClass":
+ return node.elements;
+ case "ExpressionCharacterClass":
+ return [node.expression];
+ case "CharacterClassRange":
+ return [node.min, node.max];
+ case "ClassIntersection":
+ case "ClassSubtraction":
+ return [node.left, node.right];
+ case "ClassStringDisjunction":
+ return node.alternatives;
+ case "StringAlternative":
+ return node.elements;
+ case "Modifiers":
+ return node.remove ? [node.add, node.remove] : [node.add];
+ case "Backreference":
+ case "Character":
+ case "CharacterSet":
+ case "Flags":
+ case "ModifierFlags":
+ return [];
+ }
+}
+
+function explanationFor(
+ node: AST.Node,
+ kind: RegexNodeKind,
+ captureNumber?: number,
+): string {
+ switch (node.type) {
+ case "Pattern":
+ return node.alternatives.length > 1
+ ? `Choose one of ${node.alternatives.length} alternatives`
+ : "Regular-expression pattern";
+ case "Alternative":
+ return node.elements.length > 1
+ ? `Match ${node.elements.length} items in sequence`
+ : "Pattern alternative";
+ case "CapturingGroup":
+ return node.name
+ ? `Capture as group ${captureNumber ?? "?"} named “${node.name}”`
+ : `Capture as group ${captureNumber ?? "?"}`;
+ case "Group":
+ return node.modifiers
+ ? "Apply inline flags to this non-capturing group"
+ : "Group constructs without capturing";
+ case "Quantifier": {
+ const maximum = Number.isFinite(node.max)
+ ? String(node.max)
+ : "without limit";
+ return `Repeat ${node.min} to ${maximum} times, ${
+ node.greedy ? "greedily" : "lazily"
+ }`;
+ }
+ case "Assertion":
+ if (node.kind === "start") return "Assert the start of input or line";
+ if (node.kind === "end") return "Assert the end of input or line";
+ if (node.kind === "word") {
+ return node.negate
+ ? "Assert that this is not a word boundary"
+ : "Assert a word boundary";
+ }
+ if (node.kind === "lookahead") {
+ return node.negate
+ ? "Assert that the following pattern does not match"
+ : "Assert that the following pattern matches";
+ }
+ return "negate" in node && node.negate
+ ? "Assert that the preceding pattern does not match"
+ : "Assert that the preceding pattern matches";
+ case "Character":
+ return node.raw.startsWith("\\")
+ ? `Match escaped character ${node.raw}`
+ : `Match literal ${JSON.stringify(node.raw)}`;
+ case "CharacterSet":
+ if (node.kind === "any") return "Match any allowed character";
+ if (node.kind === "property") {
+ const property = node.value ? `${node.key}=${node.value}` : node.key;
+ return `${node.negate ? "Exclude" : "Match"} Unicode property ${property}`;
+ }
+ return `Match ${node.negate ? "anything except " : ""}${node.kind} characters`;
+ case "CharacterClass":
+ return `${node.negate ? "Exclude" : "Match"} one character from this class`;
+ case "ExpressionCharacterClass":
+ return `${node.negate ? "Negated " : ""}Unicode set expression`;
+ case "CharacterClassRange":
+ return `Match a character in range ${node.raw}`;
+ case "ClassIntersection":
+ return "Match the intersection of two Unicode character sets";
+ case "ClassSubtraction":
+ return "Match the left Unicode set excluding the right set";
+ case "ClassStringDisjunction":
+ return "Match one string from this Unicode string set";
+ case "StringAlternative":
+ return "Unicode string-set alternative";
+ case "Backreference":
+ return `Match the text retained by capture ${String(node.ref)}`;
+ case "Modifiers":
+ case "ModifierFlags":
+ return "Inline regular-expression flags";
+ case "RegExpLiteral":
+ return "Regular-expression literal";
+ case "Flags":
+ return "Regular-expression flags";
+ default:
+ return kind.replaceAll("-", " ");
+ }
+}
+
+interface LengthProperties {
+ readonly zeroWidth: boolean;
+ readonly nullable: boolean | "unknown";
+ readonly minimumLength?: number;
+ readonly maximumLength?: number | null;
+ readonly consumesInput: boolean | "conditional";
+}
+
+const oneCharacterProperties: LengthProperties = {
+ zeroWidth: false,
+ nullable: false,
+ minimumLength: 1,
+ maximumLength: 1,
+ consumesInput: true,
+};
+
+function choiceProperties(
+ children: readonly NormalizedRegexNode[],
+ emptyChoice: LengthProperties,
+): LengthProperties {
+ if (children.length === 0) return emptyChoice;
+ const minimums = children.map((child) => child.properties.minimumLength);
+ const maximums = children.map((child) => child.properties.maximumLength);
+ const minimumLength = minimums.some((value) => value === undefined)
+ ? undefined
+ : Math.min(...(minimums as number[]));
+ const maximumLength = maximums.some((value) => value === undefined)
+ ? undefined
+ : maximums.some((value) => value === null)
+ ? null
+ : Math.max(...(maximums as number[]));
+ const nullable = children.some((child) => child.properties.nullable === true)
+ ? true
+ : children.every((child) => child.properties.nullable === false)
+ ? false
+ : "unknown";
+ return {
+ zeroWidth: maximumLength === 0,
+ nullable,
+ ...(minimumLength === undefined ? {} : { minimumLength }),
+ ...(maximumLength === undefined ? {} : { maximumLength }),
+ consumesInput:
+ maximumLength === 0
+ ? false
+ : minimumLength !== undefined && minimumLength > 0
+ ? true
+ : "conditional",
+ };
+}
+
+function sumMaximum(
+ children: readonly NormalizedRegexNode[],
+): number | null | undefined {
+ let total = 0;
+ for (const child of children) {
+ if (child.properties.maximumLength === undefined) return undefined;
+ if (child.properties.maximumLength === null) return null;
+ total += child.properties.maximumLength;
+ }
+ return total;
+}
+
+function propertiesFor(
+ node: AST.Node,
+ children: readonly NormalizedRegexNode[],
+): LengthProperties {
+ if (node.type === "Assertion") {
+ return {
+ zeroWidth: true,
+ nullable: true,
+ minimumLength: 0,
+ maximumLength: 0,
+ consumesInput: false,
+ };
+ }
+ if (node.type === "Character" || node.type === "CharacterClassRange") {
+ return oneCharacterProperties;
+ }
+ if (node.type === "CharacterSet") {
+ return "strings" in node && node.strings
+ ? {
+ zeroWidth: false,
+ nullable: false,
+ consumesInput: true,
+ }
+ : oneCharacterProperties;
+ }
+ if (node.type === "CharacterClass") {
+ return choiceProperties(children, oneCharacterProperties);
+ }
+ if (node.type === "ClassStringDisjunction") {
+ return choiceProperties(children, {
+ zeroWidth: true,
+ nullable: true,
+ minimumLength: 0,
+ maximumLength: 0,
+ consumesInput: false,
+ });
+ }
+ if (node.type === "ExpressionCharacterClass") {
+ return (
+ children[0]?.properties ?? {
+ zeroWidth: false,
+ nullable: "unknown",
+ consumesInput: "conditional",
+ }
+ );
+ }
+ if (node.type === "Backreference") {
+ return {
+ zeroWidth: false,
+ nullable: "unknown",
+ consumesInput: "conditional",
+ };
+ }
+ if (node.type === "Quantifier") {
+ const child = children[0];
+ const childMinimum = child?.properties.minimumLength;
+ const childMaximum = child?.properties.maximumLength;
+ const minimumLength =
+ childMinimum === undefined ? undefined : childMinimum * node.min;
+ const maximumLength =
+ childMaximum === undefined
+ ? undefined
+ : !Number.isFinite(node.max) || childMaximum === null
+ ? null
+ : childMaximum * node.max;
+ return {
+ zeroWidth: maximumLength === 0,
+ nullable:
+ node.min === 0
+ ? true
+ : child?.properties.nullable === undefined
+ ? "unknown"
+ : child.properties.nullable,
+ ...(minimumLength === undefined ? {} : { minimumLength }),
+ ...(maximumLength === undefined ? {} : { maximumLength }),
+ consumesInput:
+ maximumLength === 0
+ ? false
+ : minimumLength !== undefined && minimumLength > 0
+ ? true
+ : "conditional",
+ };
+ }
+ if (
+ node.type === "Pattern" ||
+ node.type === "CapturingGroup" ||
+ node.type === "Group"
+ ) {
+ return choiceProperties(children, {
+ zeroWidth: true,
+ nullable: true,
+ minimumLength: 0,
+ maximumLength: 0,
+ consumesInput: false,
+ });
+ }
+ if (node.type === "Alternative" || node.type === "StringAlternative") {
+ const minimumLength = children.every(
+ (child) => child.properties.minimumLength !== undefined,
+ )
+ ? children.reduce(
+ (total, child) => total + (child.properties.minimumLength ?? 0),
+ 0,
+ )
+ : undefined;
+ const maximumLength = sumMaximum(children);
+ const nullable = children.every(
+ (child) => child.properties.nullable === true,
+ )
+ ? true
+ : children.some((child) => child.properties.nullable === "unknown")
+ ? "unknown"
+ : false;
+ return {
+ zeroWidth: maximumLength === 0,
+ nullable,
+ ...(minimumLength === undefined ? {} : { minimumLength }),
+ ...(maximumLength === undefined ? {} : { maximumLength }),
+ consumesInput:
+ maximumLength === 0
+ ? false
+ : minimumLength !== undefined && minimumLength > 0
+ ? true
+ : "conditional",
+ };
+ }
+ return {
+ zeroWidth: false,
+ nullable: "unknown",
+ consumesInput: "conditional",
+ };
+}
+
+function normalizeNode(
+ node: AST.Node,
+ state: NormalizationState,
+ context: NodeContext,
+): NormalizedRegexNode {
+ const id = `syntax-${state.nextNodeId++}`;
+ const activeFlags =
+ node.type === "Group" && node.modifiers
+ ? flagsWithModifiers(context.activeFlags, node.modifiers)
+ : context.activeFlags;
+ let captureNumber: number | undefined;
+ let captureName: string | undefined;
+ if (node.type === "CapturingGroup") {
+ captureNumber = state.nextCaptureNumber++;
+ captureName = node.name ?? undefined;
+ state.captures.push({
+ number: captureNumber,
+ ...(captureName ? { name: captureName } : {}),
+ range: sourceRange(node),
+ repeated: context.repeated || node.parent.type === "Quantifier",
+ ...(context.activeCaptureNumber === undefined
+ ? {}
+ : { parentCaptureNumber: context.activeCaptureNumber }),
+ });
+ }
+
+ const childContext: NodeContext = {
+ ...(captureNumber === undefined
+ ? context.activeCaptureNumber === undefined
+ ? {}
+ : { activeCaptureNumber: context.activeCaptureNumber }
+ : { activeCaptureNumber: captureNumber }),
+ repeated: context.repeated || node.type === "Quantifier",
+ activeFlags,
+ };
+ const children = childrenOf(node).map((child) =>
+ normalizeNode(child, state, childContext),
+ );
+ const kind = nodeKind(node);
+
+ let assertion: NormalizedRegexNode["assertion"];
+ if (node.type === "Assertion") {
+ if (node.kind === "start" || node.kind === "end") {
+ assertion = { kind: node.kind };
+ } else if (node.kind === "word") {
+ assertion = {
+ kind: node.negate ? "non-word-boundary" : "word-boundary",
+ };
+ } else if (node.kind === "lookahead") {
+ assertion = { kind: node.negate ? "negative-lookahead" : "lookahead" };
+ } else {
+ assertion = {
+ kind:
+ "negate" in node && node.negate
+ ? "negative-lookbehind"
+ : "lookbehind",
+ };
+ }
+ }
+
+ return {
+ id,
+ kind,
+ range: sourceRange(node),
+ raw: node.raw,
+ explanation: explanationFor(node, kind, captureNumber),
+ children,
+ activeFlags,
+ ...(captureNumber === undefined
+ ? {}
+ : {
+ capture: {
+ number: captureNumber,
+ ...(captureName ? { name: captureName } : {}),
+ },
+ }),
+ ...(node.type === "Quantifier"
+ ? {
+ quantifier: {
+ minimum: node.min,
+ maximum: Number.isFinite(node.max) ? node.max : null,
+ mode: node.greedy ? ("greedy" as const) : ("lazy" as const),
+ },
+ }
+ : {}),
+ ...(assertion ? { assertion } : {}),
+ properties: propertiesFor(node, children),
+ support: {
+ flavour: "ecmascript",
+ status: "supported",
+ notes: [],
+ },
+ provenance: {
+ provider: PROVIDER,
+ providerVersion: PROVIDER_VERSION,
+ source: "parsed",
+ },
+ };
+}
+
+function collectTokens(
+ root: NormalizedRegexNode,
+): readonly NormalizedRegexToken[] {
+ const tokens: NormalizedRegexToken[] = [];
+ const visit = (node: NormalizedRegexNode) => {
+ if (
+ node.kind !== "pattern" &&
+ node.kind !== "disjunction" &&
+ node.kind !== "sequence" &&
+ node.kind !== "alternative" &&
+ node.range.endUtf16 > node.range.startUtf16
+ ) {
+ tokens.push({
+ id: `token-${node.id}`,
+ kind: node.kind,
+ range: node.range,
+ raw: node.raw,
+ ...(node.capture ? { captureNumber: node.capture.number } : {}),
+ });
+ }
+ node.children.forEach(visit);
+ };
+ visit(root);
+ return tokens.sort(
+ (left, right) =>
+ left.range.startUtf16 - right.range.startUtf16 ||
+ right.range.endUtf16 - left.range.endUtf16,
+ );
+}
+
+export interface NormalizedPattern {
+ readonly root: NormalizedRegexNode;
+ readonly tokens: readonly NormalizedRegexToken[];
+ readonly captures: readonly CaptureDefinition[];
+}
+
+export function normalizePattern(
+ pattern: AST.Pattern,
+ flags: readonly string[] = [],
+): NormalizedPattern {
+ const state: NormalizationState = {
+ nextNodeId: 1,
+ nextCaptureNumber: 1,
+ captures: [],
+ };
+ const root = normalizeNode(pattern, state, {
+ repeated: false,
+ activeFlags: FLAG_ORDER.filter((flag) => flags.includes(flag)),
+ });
+ return {
+ root,
+ tokens: collectTokens(root),
+ captures: state.captures,
+ };
+}
+
+export function withRecoveryNode(
+ normalized: NormalizedPattern,
+ completePattern: string,
+ errorRange: SourceRange,
+ message: string,
+): NormalizedPattern {
+ const recovery: NormalizedRegexNode = {
+ id: "syntax-recovery",
+ kind: "error-recovery",
+ range: errorRange,
+ raw: completePattern.slice(errorRange.startUtf16, errorRange.endUtf16),
+ explanation: message,
+ children: [],
+ properties: {
+ zeroWidth: false,
+ nullable: "unknown",
+ consumesInput: "conditional",
+ },
+ support: {
+ flavour: "ecmascript",
+ status: "partial",
+ notes: [
+ "regexpp does not expose tolerant recovery; this application error node marks the reported range.",
+ ],
+ },
+ provenance: {
+ provider: PROVIDER,
+ providerVersion: PROVIDER_VERSION,
+ source: "derived",
+ },
+ };
+ const root: NormalizedRegexNode = {
+ ...normalized.root,
+ kind: "pattern",
+ range: { startUtf16: 0, endUtf16: completePattern.length },
+ raw: completePattern,
+ explanation: "Malformed pattern with an application-owned error node",
+ children: [...normalized.root.children, recovery],
+ support: {
+ flavour: "ecmascript",
+ status: "partial",
+ notes: [
+ "Execution is disabled until the engine accepts the pattern.",
+ "No partial provider AST is claimed.",
+ ],
+ },
+ provenance: {
+ provider: PROVIDER,
+ providerVersion: PROVIDER_VERSION,
+ source: "derived",
+ },
+ };
+ return {
+ root,
+ tokens: [
+ ...normalized.tokens,
+ {
+ id: "token-syntax-recovery",
+ kind: "error-recovery",
+ range: errorRange,
+ raw: recovery.raw,
+ },
+ ],
+ captures: normalized.captures,
+ };
+}
+
+export function emptyRecoveredPattern(
+ completePattern: string,
+ errorRange: SourceRange,
+ message: string,
+ flags: readonly string[] = [],
+): NormalizedPattern {
+ const root: NormalizedRegexNode = {
+ id: "syntax-root",
+ kind: "pattern",
+ range: { startUtf16: 0, endUtf16: completePattern.length },
+ raw: completePattern,
+ explanation: "Malformed regular-expression pattern",
+ children: [],
+ activeFlags: FLAG_ORDER.filter((flag) => flags.includes(flag)),
+ properties: {
+ zeroWidth: false,
+ nullable: "unknown",
+ consumesInput: "conditional",
+ },
+ support: {
+ flavour: "ecmascript",
+ status: "partial",
+ notes: ["The provider does not expose tolerant syntax recovery."],
+ },
+ provenance: {
+ provider: PROVIDER,
+ providerVersion: PROVIDER_VERSION,
+ source: "derived",
+ },
+ };
+ return withRecoveryNode(
+ { root, tokens: [], captures: [] },
+ completePattern,
+ errorRange,
+ message,
+ );
+}
diff --git a/src/regex/syntax/providers/ecmascript/replacement.ts b/src/regex/syntax/providers/ecmascript/replacement.ts
new file mode 100644
index 0000000..34665bd
--- /dev/null
+++ b/src/regex/syntax/providers/ecmascript/replacement.ts
@@ -0,0 +1,247 @@
+import type {
+ CaptureDefinition,
+ ReplacementSyntaxResult,
+ ReplacementToken,
+} from "../../../model/syntax";
+import type { RegexDiagnostic } from "../../../model/diagnostics";
+import {
+ DEFAULT_REGEX_LIMITS,
+ TEMPLATE_PRESENTATION_LIMITS,
+} from "../../../execution/request-limits";
+
+const MAXIMUM_LITERAL_EXPLANATION_UTF16 = 160;
+
+function literalExplanation(raw: string): string {
+ const truncated = raw.length > MAXIMUM_LITERAL_EXPLANATION_UTF16;
+ const preview = truncated
+ ? `${raw.slice(0, MAXIMUM_LITERAL_EXPLANATION_UTF16)}…`
+ : raw;
+ return `Insert literal text ${JSON.stringify(preview)}${
+ truncated ? ` (${raw.length.toLocaleString()} UTF-16 units total)` : ""
+ }`;
+}
+
+function token(
+ index: number,
+ kind: ReplacementToken["kind"],
+ raw: string,
+ start: number,
+ explanation: string,
+ capture?: { number?: number; name?: string },
+): ReplacementToken {
+ return {
+ id: `replacement-${index}`,
+ kind,
+ raw,
+ range: { startUtf16: start, endUtf16: start + raw.length },
+ explanation,
+ ...(capture?.number === undefined ? {} : { captureNumber: capture.number }),
+ ...(capture?.name === undefined ? {} : { captureName: capture.name }),
+ };
+}
+
+export function parseEcmaScriptReplacement(
+ replacement: string,
+ captures: readonly CaptureDefinition[],
+): ReplacementSyntaxResult {
+ if (
+ replacement.length > DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16
+ ) {
+ return {
+ accepted: false,
+ tokens: [],
+ diagnostics: [
+ {
+ id: "replacement-template-input-limit",
+ source: "syntax-provider",
+ severity: "error",
+ code: "replacement-template-limit",
+ message: `Replacement template exceeds the ${DEFAULT_REGEX_LIMITS.maximumReplacementTemplateUtf16.toLocaleString()} UTF-16 unit limit; it was not parsed or executed.`,
+ flavour: "ecmascript",
+ provenance: "derived",
+ },
+ ],
+ totalDiagnostics: 1,
+ diagnosticsTruncated: false,
+ };
+ }
+ const tokens: ReplacementToken[] = [];
+ const diagnostics: RegexDiagnostic[] = [];
+ let totalDiagnostics = 0;
+ const byNumber = new Map(
+ captures.map((capture) => [capture.number, capture]),
+ );
+ const byName = new Map(
+ captures
+ .filter(
+ (capture): capture is CaptureDefinition & { name: string } =>
+ capture.name !== undefined,
+ )
+ .map((capture) => [capture.name, capture]),
+ );
+ const hasNamedCaptures = byName.size > 0;
+ let cursor = 0;
+ let literalStart = 0;
+
+ const addDiagnostic = (diagnostic: RegexDiagnostic) => {
+ totalDiagnostics += 1;
+ if (
+ diagnostics.length < TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics
+ ) {
+ diagnostics.push(diagnostic);
+ }
+ };
+
+ const flushLiteral = (end: number) => {
+ if (end <= literalStart) return;
+ const raw = replacement.slice(literalStart, end);
+ tokens.push(
+ token(
+ tokens.length,
+ "literal",
+ raw,
+ literalStart,
+ literalExplanation(raw),
+ ),
+ );
+ };
+
+ while (cursor < replacement.length) {
+ if (replacement[cursor] !== "$" || cursor + 1 >= replacement.length) {
+ cursor += 1;
+ continue;
+ }
+ const next = replacement[cursor + 1] ?? "";
+ let raw: string | undefined;
+ let replacementToken: ReplacementToken | undefined;
+ if (next === "$") {
+ raw = "$$";
+ replacementToken = token(
+ tokens.length,
+ "escaped-dollar",
+ raw,
+ cursor,
+ "Insert one literal dollar sign",
+ );
+ } else if (next === "&") {
+ raw = "$&";
+ replacementToken = token(
+ tokens.length,
+ "full-match",
+ raw,
+ cursor,
+ "Insert the complete match",
+ );
+ } else if (next === "`") {
+ raw = "$`";
+ replacementToken = token(
+ tokens.length,
+ "prefix",
+ raw,
+ cursor,
+ "Insert the subject text before this match",
+ );
+ } else if (next === "'") {
+ raw = "$'";
+ replacementToken = token(
+ tokens.length,
+ "suffix",
+ raw,
+ cursor,
+ "Insert the subject text after this match",
+ );
+ } else if (next === "<") {
+ const end = replacement.indexOf(">", cursor + 2);
+ if (end >= 0 && hasNamedCaptures) {
+ raw = replacement.slice(cursor, end + 1);
+ const name = replacement.slice(cursor + 2, end);
+ const definition = byName.get(name);
+ replacementToken = token(
+ tokens.length,
+ "named-capture",
+ raw,
+ cursor,
+ definition
+ ? `Insert named capture “${name}”`
+ : `No capture named “${name}” is defined; ECMAScript inserts empty text`,
+ { name },
+ );
+ if (!definition) {
+ addDiagnostic({
+ id: `replacement-missing-name-${cursor}`,
+ source: "syntax-provider",
+ severity: "warning",
+ code: "replacement-unknown-capture",
+ message: `Replacement references unknown named capture “${name}”.`,
+ range: { startUtf16: cursor, endUtf16: end + 1 },
+ flavour: "ecmascript",
+ provenance: "derived",
+ });
+ }
+ }
+ } else if (/\d/u.test(next)) {
+ const second = replacement[cursor + 2] ?? "";
+ const twoDigit = /\d/u.test(second)
+ ? Number.parseInt(`${next}${second}`, 10)
+ : undefined;
+ const oneDigit = Number.parseInt(next, 10);
+ const number = byNumber.has(twoDigit ?? -1)
+ ? twoDigit
+ : byNumber.has(oneDigit)
+ ? oneDigit
+ : undefined;
+ if (number === undefined || number === 0) {
+ cursor += 1;
+ continue;
+ }
+ const consumesTwoDigits = twoDigit === number;
+ raw = replacement.slice(cursor, cursor + (consumesTwoDigits ? 3 : 2));
+ replacementToken = token(
+ tokens.length,
+ "numbered-capture",
+ raw,
+ cursor,
+ `Insert numbered capture ${number}`,
+ { number },
+ );
+ }
+
+ if (!raw || !replacementToken) {
+ cursor += 1;
+ continue;
+ }
+ flushLiteral(cursor);
+ tokens.push({
+ ...replacementToken,
+ id: `replacement-${tokens.length}`,
+ });
+ cursor += raw.length;
+ literalStart = cursor;
+ }
+ flushLiteral(replacement.length);
+ const diagnosticsTruncated = totalDiagnostics > diagnostics.length;
+ if (
+ diagnosticsTruncated &&
+ TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics > 0
+ ) {
+ const retainedDetails =
+ TEMPLATE_PRESENTATION_LIMITS.replacementDiagnostics - 1;
+ const omitted = totalDiagnostics - retainedDetails;
+ diagnostics.splice(retainedDetails, diagnostics.length - retainedDetails, {
+ id: "replacement-diagnostic-render-limit",
+ source: "syntax-provider",
+ severity: "warning",
+ code: "replacement-diagnostic-limit",
+ message: `${omitted.toLocaleString()} additional replacement diagnostics were not materialized; the template contains ${totalDiagnostics.toLocaleString()} diagnostics in total.`,
+ flavour: "ecmascript",
+ provenance: "derived",
+ });
+ }
+ return {
+ accepted: true,
+ tokens,
+ diagnostics,
+ totalDiagnostics,
+ diagnosticsTruncated,
+ };
+}
diff --git a/src/regex/tests/current-result-draft.test.ts b/src/regex/tests/current-result-draft.test.ts
new file mode 100644
index 0000000..9a22263
--- /dev/null
+++ b/src/regex/tests/current-result-draft.test.ts
@@ -0,0 +1,55 @@
+import { describe, expect, it } from "vitest";
+import {
+ importRegexTestSuite,
+ serializeRegexTestSuite,
+} from "./test-suite.serialization";
+import type { RegexTestCase } from "./test-case.types";
+import { chooseReplacementResultDraft } from "./current-result-draft";
+
+function testWithExpectation(
+ expectation: RegexTestCase["expectation"],
+): RegexTestCase {
+ return {
+ id: "current-result",
+ name: "Current result",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "x",
+ flags: ["g"],
+ options: {},
+ subject: "x",
+ replacement: "y",
+ expectation,
+ };
+}
+
+describe("current replacement result draft", () => {
+ it("falls back to a round-trippable lightweight assertion above the exact-value limit", () => {
+ const decision = chooseReplacementResultDraft({
+ output: "oversized",
+ resultTruncated: false,
+ matchCount: 1,
+ maximumExpectedUtf16: 4,
+ canStore: () => true,
+ });
+
+ expect(decision.expectation).toEqual({ kind: "should-match" });
+ expect(decision.notice).toContain("expected-value limit");
+ const test = testWithExpectation(decision.expectation!);
+ expect(
+ importRegexTestSuite(serializeRegexTestSuite([test], true)).tests,
+ ).toEqual([test]);
+ });
+
+ it("falls back when exact output would exceed the aggregate suite limit", () => {
+ const decision = chooseReplacementResultDraft({
+ output: "exact",
+ resultTruncated: false,
+ matchCount: 1,
+ canStore: (expectation) => expectation.kind !== "replacement",
+ });
+
+ expect(decision.expectation).toEqual({ kind: "should-match" });
+ expect(decision.notice).toContain("32 MiB");
+ });
+});
diff --git a/src/regex/tests/current-result-draft.ts b/src/regex/tests/current-result-draft.ts
new file mode 100644
index 0000000..005cd36
--- /dev/null
+++ b/src/regex/tests/current-result-draft.ts
@@ -0,0 +1,45 @@
+import type { RegexTestExpectation } from "./test-case.types";
+import { MAXIMUM_REGEX_TEST_EXPECTED_UTF16 } from "./test-case.types";
+
+export interface ReplacementResultDraftDecision {
+ readonly expectation?: Extract<
+ RegexTestExpectation,
+ { readonly kind: "replacement" | "should-match" }
+ >;
+ readonly notice?: string;
+}
+
+export function chooseReplacementResultDraft({
+ output,
+ resultTruncated,
+ matchCount,
+ canStore,
+ maximumExpectedUtf16 = MAXIMUM_REGEX_TEST_EXPECTED_UTF16,
+}: {
+ readonly output: string;
+ readonly resultTruncated: boolean;
+ readonly matchCount: number;
+ readonly canStore: (expectation: RegexTestExpectation) => boolean;
+ readonly maximumExpectedUtf16?: number;
+}): ReplacementResultDraftDecision {
+ let notice: string | undefined;
+ if (!resultTruncated) {
+ if (output.length <= maximumExpectedUtf16) {
+ const expectation = { kind: "replacement", expected: output } as const;
+ if (canStore(expectation)) return { expectation };
+ notice =
+ "An exact replacement assertion is not offered because it would exceed the 32 MiB test-suite state limit.";
+ } else {
+ notice = `An exact replacement assertion is not offered because the output exceeds the ${maximumExpectedUtf16.toLocaleString()} UTF-16 unit expected-value limit.`;
+ }
+ }
+
+ if (matchCount > 0) {
+ const expectation = { kind: "should-match" } as const;
+ if (canStore(expectation))
+ return { expectation, ...(notice ? { notice } : {}) };
+ notice =
+ "A current-result assertion is not offered because it would exceed the 32 MiB test-suite state limit.";
+ }
+ return notice ? { notice } : {};
+}
diff --git a/src/regex/tests/test-case.types.ts b/src/regex/tests/test-case.types.ts
new file mode 100644
index 0000000..02909d3
--- /dev/null
+++ b/src/regex/tests/test-case.types.ts
@@ -0,0 +1,57 @@
+import type { RegexFlavourId } from "../model/flavour";
+import type { RegexResourceLimits } from "../execution/request-limits";
+
+export const MAXIMUM_REGEX_TESTS = 1_000;
+export const MAXIMUM_REGEX_TEST_NAME_UTF16 = 256;
+export const MAXIMUM_REGEX_TEST_PATTERN_UTF16 = 64 * 1024;
+export const MAXIMUM_REGEX_TEST_SUBJECT_UTF16 = 16 * 1024 * 1024;
+export const MAXIMUM_REGEX_TEST_EXPECTED_UTF16 = 16 * 1024 * 1024;
+export const MAXIMUM_REGEX_TEST_CAPTURE_NAME_UTF16 = 256;
+export const MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES = 2 * 1024;
+
+export type RegexTestExpectation =
+ | { readonly kind: "should-match" }
+ | { readonly kind: "should-not-match" }
+ | { readonly kind: "match-count"; readonly count: number }
+ | {
+ readonly kind: "full-match";
+ readonly matchIndex: number;
+ readonly value: string;
+ }
+ | {
+ readonly kind: "capture";
+ readonly matchIndex: number;
+ readonly groupNumber?: number;
+ readonly groupName?: string;
+ readonly status: "participated" | "did-not-participate" | "matched-empty";
+ readonly value?: string;
+ }
+ | { readonly kind: "replacement"; readonly expected: string }
+ | { readonly kind: "must-complete-within"; readonly milliseconds: number }
+ | { readonly kind: "must-time-out" };
+
+export interface RegexTestCase {
+ readonly id: string;
+ readonly name: string;
+ readonly enabled: boolean;
+ readonly flavour: RegexFlavourId;
+ readonly flavourVersion?: string;
+ readonly pattern: string;
+ readonly flags: readonly string[];
+ readonly options: Readonly>;
+ /** Explicitly iterate matches even when the saved user flags omit g/y. */
+ readonly scanAll?: boolean;
+ readonly subject: string;
+ readonly replacement?: string;
+ readonly expectation: RegexTestExpectation;
+ readonly resourceLimits?: Partial;
+}
+
+export interface RegexTestResult {
+ readonly testId: string;
+ readonly passed: boolean;
+ readonly elapsedMs: number;
+ readonly message: string;
+ readonly engineIdentity?: string;
+ readonly timedOut: boolean;
+}
diff --git a/src/regex/tests/test-runner.test.ts b/src/regex/tests/test-runner.test.ts
new file mode 100644
index 0000000..8d36b03
--- /dev/null
+++ b/src/regex/tests/test-runner.test.ts
@@ -0,0 +1,371 @@
+import { describe, expect, it } from "vitest";
+import type {
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementResult,
+} from "../model/match";
+import {
+ MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+ type RegexTestExpectation,
+} from "./test-case.types";
+import { runRegexTest } from "./test-runner";
+
+const rejectedExecution: RegexExecutionResult = {
+ accepted: false,
+ engine: {
+ flavour: "ecmascript",
+ adapterVersion: "test",
+ engineName: "Test ECMAScript engine",
+ engineVersion: "test",
+ offsetUnit: "utf16",
+ capabilities: {
+ compilation: true,
+ matching: true,
+ replacement: true,
+ namedCaptures: true,
+ captureHistory: false,
+ actualTrace: false,
+ benchmark: false,
+ },
+ },
+ flags: {
+ userFlags: "",
+ effectiveFlags: "",
+ internallyAddedIndicesFlag: false,
+ internallyAddedGlobalFlag: false,
+ },
+ matches: [],
+ diagnostics: [
+ {
+ id: "compile-error",
+ source: "execution-engine",
+ severity: "error",
+ code: "compile-error",
+ message: "Unsupported regular-expression construct.",
+ flavour: "ecmascript",
+ provenance: "reported",
+ },
+ ],
+ elapsedMs: 1,
+ truncated: false,
+};
+
+const rejectedReplacement: RegexReplacementResult = {
+ execution: rejectedExecution,
+ output: "subject",
+ outputBytes: 7,
+ outputTruncated: false,
+ truncated: false,
+};
+
+function runWithResult(
+ expectation: RegexTestExpectation,
+ execution: RegexExecutionResult,
+ subject = "subject",
+ replacementResult: RegexReplacementResult = {
+ execution,
+ output: subject,
+ outputBytes: subject.length,
+ outputTruncated: false,
+ truncated: false,
+ },
+) {
+ return runRegexTest(
+ {
+ id: "test",
+ name: "Test",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "pattern",
+ flags: [],
+ options: {},
+ subject,
+ replacement: "replacement",
+ expectation,
+ },
+ [],
+ {
+ execute: () => Promise.resolve(execution),
+ replace: () => Promise.resolve(replacementResult),
+ },
+ 2_000,
+ 10_000,
+ 100_000,
+ 64 * 1024 * 1024,
+ );
+}
+
+describe("regex unit-test runner", () => {
+ it("preserves explicit scan-all behavior in a saved test", async () => {
+ let request: RegexExecutionRequest | undefined;
+ await runRegexTest(
+ {
+ id: "scan-all",
+ name: "Scan all",
+ enabled: true,
+ flavour: "ecmascript",
+ pattern: "a",
+ flags: [],
+ options: {},
+ scanAll: true,
+ subject: "aa",
+ expectation: { kind: "should-not-match" },
+ },
+ [],
+ {
+ execute: (candidate) => {
+ request = candidate;
+ return Promise.resolve(rejectedExecution);
+ },
+ replace: () => Promise.resolve(rejectedReplacement),
+ },
+ 2_000,
+ 10_000,
+ 100_000,
+ 64 * 1024 * 1024,
+ );
+
+ expect(request?.scanAll).toBe(true);
+ });
+
+ it("never treats engine compile rejection as a successful no-match", async () => {
+ const result = await runWithResult(
+ { kind: "should-not-match" },
+ rejectedExecution,
+ "subject",
+ rejectedReplacement,
+ );
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ passed: false,
+ timedOut: false,
+ message:
+ "Execution engine rejected the pattern: Unsupported regular-expression construct.",
+ }),
+ );
+ });
+
+ it("uses exact subject ranges instead of truncated value previews", async () => {
+ const subject = "x".repeat(70_000);
+ const preview = subject.slice(0, 64 * 1024);
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ matches: [
+ {
+ matchNumber: 1,
+ value: preview,
+ valueStatus: "truncated",
+ range: { startUtf16: 0, endUtf16: subject.length },
+ nativeRange: { start: 0, end: subject.length, unit: "utf16" },
+ captures: [
+ {
+ groupNumber: 1,
+ value: preview,
+ status: "truncated",
+ range: { startUtf16: 0, endUtf16: subject.length },
+ nativeRange: { start: 0, end: subject.length, unit: "utf16" },
+ },
+ ],
+ },
+ ],
+ truncated: false,
+ };
+
+ await expect(
+ runWithResult(
+ { kind: "full-match", matchIndex: 0, value: subject },
+ execution,
+ subject,
+ ),
+ ).resolves.toMatchObject({ passed: true });
+ await expect(
+ runWithResult(
+ {
+ kind: "capture",
+ matchIndex: 0,
+ groupNumber: 1,
+ status: "participated",
+ value: subject,
+ },
+ execution,
+ subject,
+ ),
+ ).resolves.toMatchObject({ passed: true });
+ });
+
+ it("uses the participating capture for a duplicate named group", async () => {
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ matches: [
+ {
+ matchNumber: 1,
+ value: "selected",
+ valueStatus: "complete",
+ range: { startUtf16: 0, endUtf16: 8 },
+ nativeRange: { start: 0, end: 8, unit: "utf16" },
+ captures: [
+ {
+ groupNumber: 1,
+ groupName: "value",
+ status: "did-not-participate",
+ },
+ {
+ groupNumber: 2,
+ groupName: "value",
+ value: "selected",
+ status: "participated",
+ range: { startUtf16: 0, endUtf16: 8 },
+ nativeRange: { start: 0, end: 8, unit: "utf16" },
+ },
+ ],
+ },
+ ],
+ truncated: false,
+ };
+
+ await expect(
+ runWithResult(
+ {
+ kind: "capture",
+ matchIndex: 0,
+ groupName: "value",
+ status: "participated",
+ value: "selected",
+ },
+ execution,
+ "selected",
+ ),
+ ).resolves.toMatchObject({ passed: true });
+ });
+
+ it("does not assert complete counts from truncated match collection", async () => {
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ matches: [
+ {
+ matchNumber: 1,
+ value: "x",
+ valueStatus: "complete",
+ range: { startUtf16: 0, endUtf16: 1 },
+ nativeRange: { start: 0, end: 1, unit: "utf16" },
+ captures: [],
+ },
+ ],
+ truncated: true,
+ };
+
+ await expect(
+ runWithResult({ kind: "match-count", count: 1 }, execution, "x"),
+ ).resolves.toMatchObject({
+ passed: false,
+ message: expect.stringContaining("complete result set"),
+ });
+ await expect(
+ runWithResult({ kind: "should-match" }, execution, "x"),
+ ).resolves.toMatchObject({ passed: true });
+ });
+
+ it("does not assert equality from truncated replacement output", async () => {
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ };
+ const replacement: RegexReplacementResult = {
+ execution,
+ output: "prefix",
+ outputBytes: 6,
+ outputTruncated: true,
+ truncated: true,
+ };
+
+ await expect(
+ runWithResult(
+ { kind: "replacement", expected: "prefix" },
+ execution,
+ "subject",
+ replacement,
+ ),
+ ).resolves.toMatchObject({
+ passed: false,
+ message: expect.stringContaining("truncated"),
+ });
+ });
+
+ it("does not assert replacement equality from truncated match collection", async () => {
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ matches: [
+ {
+ matchNumber: 1,
+ value: "a",
+ valueStatus: "complete",
+ range: { startUtf16: 0, endUtf16: 1 },
+ nativeRange: { start: 0, end: 1, unit: "utf16" },
+ captures: [],
+ },
+ ],
+ truncated: true,
+ };
+ const replacement: RegexReplacementResult = {
+ execution,
+ output: "xa",
+ outputBytes: 2,
+ outputTruncated: false,
+ truncated: true,
+ };
+
+ await expect(
+ runWithResult(
+ { kind: "replacement", expected: "xa" },
+ execution,
+ "aa",
+ replacement,
+ ),
+ ).resolves.toMatchObject({
+ passed: false,
+ message: expect.stringContaining("match collection was truncated"),
+ });
+ });
+
+ it("bounds retained mismatch diagnostics without stringifying complete values", async () => {
+ const expected = `${"expected".repeat(200_000)}expected-tail`;
+ const actual = `${"actual".repeat(300_000)}actual-tail`;
+ const execution: RegexExecutionResult = {
+ ...rejectedExecution,
+ accepted: true,
+ diagnostics: [],
+ };
+ const replacement: RegexReplacementResult = {
+ execution,
+ output: actual,
+ outputBytes: actual.length,
+ outputTruncated: false,
+ truncated: false,
+ };
+
+ const result = await runWithResult(
+ { kind: "replacement", expected },
+ execution,
+ "subject",
+ replacement,
+ );
+
+ expect(result.passed).toBe(false);
+ expect(new TextEncoder().encode(result.message).length).toBeLessThanOrEqual(
+ MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+ );
+ expect(result.message).toContain("diagnostic preview");
+ expect(result.message).not.toContain("expected-tail");
+ expect(result.message).not.toContain("actual-tail");
+ });
+});
diff --git a/src/regex/tests/test-runner.ts b/src/regex/tests/test-runner.ts
new file mode 100644
index 0000000..4ac34c4
--- /dev/null
+++ b/src/regex/tests/test-runner.ts
@@ -0,0 +1,269 @@
+import type {
+ RegexExecutionRequest,
+ RegexExecutionResult,
+ RegexReplacementRequest,
+ RegexReplacementResult,
+} from "../model/match";
+import type { CaptureDefinition } from "../model/syntax";
+import type {
+ RegexTestCase,
+ RegexTestExpectation,
+ RegexTestResult,
+} from "./test-case.types";
+import { MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES } from "./test-case.types";
+import { utf8ByteLength } from "../execution/request-limits";
+
+const MAXIMUM_VALUE_PREVIEW_UTF16 = 256;
+
+export interface TestExecution {
+ execute(
+ request: RegexExecutionRequest,
+ timeoutMs: number,
+ ): Promise;
+ replace(
+ request: RegexReplacementRequest,
+ timeoutMs: number,
+ ): Promise;
+}
+
+function valuePreview(value: string | undefined): string {
+ if (value === undefined) return "undefined";
+ const visible = value.slice(0, MAXIMUM_VALUE_PREVIEW_UTF16);
+ const encoded = JSON.stringify(visible);
+ return value.length > MAXIMUM_VALUE_PREVIEW_UTF16
+ ? `${encoded}… (${value.length.toLocaleString()} UTF-16 units total; diagnostic preview)`
+ : encoded;
+}
+
+export function boundedTestResultMessage(
+ message: string,
+ maximumBytes = MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+): string {
+ if (maximumBytes <= 0) return "";
+ if (
+ message.length <= maximumBytes &&
+ utf8ByteLength(message) <= maximumBytes
+ ) {
+ return message;
+ }
+
+ const suffix = `… (${message.length.toLocaleString()} UTF-16 units total; diagnostic truncated)`;
+ const suffixBytes = utf8ByteLength(suffix);
+ if (suffixBytes >= maximumBytes) return suffix.slice(0, maximumBytes);
+
+ const prefixBudget = maximumBytes - suffixBytes;
+ let low = 0;
+ let high = Math.min(message.length, prefixBudget);
+ while (low < high) {
+ const middle = Math.ceil((low + high) / 2);
+ if (utf8ByteLength(message.slice(0, middle)) <= prefixBudget) {
+ low = middle;
+ } else {
+ high = middle - 1;
+ }
+ }
+ return `${message.slice(0, low)}${suffix}`;
+}
+
+function evaluate(
+ expectation: RegexTestExpectation,
+ execution: RegexExecutionResult,
+ replacement: RegexReplacementResult | undefined,
+ subject: string,
+): { readonly passed: boolean; readonly message: string } {
+ const matches = execution.matches;
+ if (
+ execution.truncated &&
+ (expectation.kind === "should-not-match" ||
+ expectation.kind === "match-count" ||
+ expectation.kind === "must-complete-within")
+ ) {
+ return {
+ passed: false,
+ message:
+ "The engine truncated match collection; this expectation requires a complete result set.",
+ };
+ }
+ switch (expectation.kind) {
+ case "should-match":
+ return {
+ passed: matches.length > 0,
+ message:
+ matches.length > 0
+ ? `Matched ${matches.length} time(s).`
+ : "Expected at least one match.",
+ };
+ case "should-not-match":
+ return {
+ passed: matches.length === 0,
+ message:
+ matches.length === 0
+ ? "No match, as expected."
+ : `Expected no match; received ${matches.length}.`,
+ };
+ case "match-count":
+ return {
+ passed: matches.length === expectation.count,
+ message: `Expected ${expectation.count} match(es); received ${matches.length}.`,
+ };
+ case "full-match": {
+ const match = matches[expectation.matchIndex];
+ const actual = match
+ ? subject.slice(match.range.startUtf16, match.range.endUtf16)
+ : undefined;
+ return {
+ passed: actual === expectation.value,
+ message:
+ actual === expectation.value
+ ? "Full match equals the expected value."
+ : `Expected ${valuePreview(expectation.value)}; received ${valuePreview(actual)}.`,
+ };
+ }
+ case "capture": {
+ const match = matches[expectation.matchIndex];
+ const namedCaptures = expectation.groupName
+ ? match?.captures.filter(
+ (candidate) => candidate.groupName === expectation.groupName,
+ )
+ : undefined;
+ const capture =
+ namedCaptures?.find(
+ (candidate) => candidate.status !== "did-not-participate",
+ ) ??
+ namedCaptures?.[0] ??
+ match?.captures.find(
+ (candidate) => candidate.groupNumber === expectation.groupNumber,
+ );
+ const actualStatus =
+ capture?.status === "truncated" ? "participated" : capture?.status;
+ const statusMatches = actualStatus === expectation.status;
+ const actualValue = capture?.range
+ ? subject.slice(capture.range.startUtf16, capture.range.endUtf16)
+ : capture?.status === "truncated"
+ ? undefined
+ : capture?.value;
+ const valueMatches =
+ expectation.value === undefined || actualValue === expectation.value;
+ return {
+ passed: statusMatches && valueMatches,
+ message:
+ statusMatches && valueMatches
+ ? "Capture status and value match."
+ : `Capture received ${actualStatus ?? "unavailable"} ${valuePreview(actualValue)}.`,
+ };
+ }
+ case "replacement":
+ if (replacement?.execution.truncated) {
+ return {
+ passed: false,
+ message:
+ "Replacement match collection was truncated; exact equality cannot be asserted.",
+ };
+ }
+ if (replacement?.outputTruncated) {
+ return {
+ passed: false,
+ message:
+ "Replacement output was truncated; exact equality cannot be asserted.",
+ };
+ }
+ return {
+ passed: replacement?.output === expectation.expected,
+ message:
+ replacement?.output === expectation.expected
+ ? "Replacement equals the expected output."
+ : `Expected ${valuePreview(expectation.expected)}; received ${valuePreview(replacement?.output)}.`,
+ };
+ case "must-complete-within":
+ return {
+ passed: execution.elapsedMs <= expectation.milliseconds,
+ message: `Engine completed in ${execution.elapsedMs.toFixed(2)} ms (limit ${expectation.milliseconds} ms).`,
+ };
+ case "must-time-out":
+ return {
+ passed: false,
+ message: "The engine completed; a timeout was expected.",
+ };
+ }
+}
+
+export async function runRegexTest(
+ test: RegexTestCase,
+ captures: readonly CaptureDefinition[],
+ executor: TestExecution,
+ defaultTimeoutMs: number,
+ maximumMatches: number,
+ maximumCaptureRows: number,
+ maximumOutputBytes: number,
+ maximumMessageBytes = MAXIMUM_REGEX_TEST_RESULT_MESSAGE_BYTES,
+): Promise {
+ const timeoutMs =
+ test.resourceLimits?.manualExecutionTimeoutMs ?? defaultTimeoutMs;
+ const request: RegexExecutionRequest = {
+ flavour: test.flavour,
+ pattern: test.pattern,
+ flags: test.flags,
+ subject: test.subject,
+ captureMetadata: captures,
+ scanAll: test.scanAll ?? false,
+ maximumMatches,
+ maximumCaptureRows,
+ };
+ const started = performance.now();
+ try {
+ let replacementResult: RegexReplacementResult | undefined;
+ const execution =
+ test.expectation.kind === "replacement"
+ ? (replacementResult = await executor.replace(
+ {
+ ...request,
+ replacement: test.replacement ?? "",
+ maximumOutputBytes,
+ },
+ timeoutMs,
+ )).execution
+ : await executor.execute(request, timeoutMs);
+ const evaluation = execution.accepted
+ ? evaluate(test.expectation, execution, replacementResult, test.subject)
+ : {
+ passed: false,
+ message: `Execution engine rejected the pattern: ${boundedTestResultMessage(
+ execution.diagnostics[0]?.message ?? "compile failed",
+ maximumMessageBytes,
+ )}`,
+ };
+ return {
+ testId: test.id,
+ ...evaluation,
+ message: boundedTestResultMessage(
+ evaluation.message,
+ maximumMessageBytes,
+ ),
+ elapsedMs: performance.now() - started,
+ engineIdentity: `${execution.engine.engineName} — ${execution.engine.engineVersion}`,
+ timedOut: false,
+ };
+ } catch (error) {
+ const timedOut =
+ error instanceof Error &&
+ "kind" in error &&
+ (error as { readonly kind?: string }).kind === "timeout";
+ const expectedTimeout = test.expectation.kind === "must-time-out";
+ return {
+ testId: test.id,
+ passed: timedOut && expectedTimeout,
+ elapsedMs: performance.now() - started,
+ message: boundedTestResultMessage(
+ timedOut
+ ? expectedTimeout
+ ? "Execution timed out as expected; the worker was terminated."
+ : "Execution timed out; the worker was terminated."
+ : error instanceof Error
+ ? error.message
+ : String(error),
+ maximumMessageBytes,
+ ),
+ timedOut,
+ };
+ }
+}
diff --git a/src/regex/tests/test-suite.serialization.test.ts b/src/regex/tests/test-suite.serialization.test.ts
new file mode 100644
index 0000000..0c5bd52
--- /dev/null
+++ b/src/regex/tests/test-suite.serialization.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from "vitest";
+import type { RegexTestCase } from "./test-case.types";
+import { MAXIMUM_REGEX_TESTS } from "./test-case.types";
+import {
+ assertRegexTestSuiteWithinLimit,
+ importRegexTestSuite,
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
+ serializeRegexTestSuite,
+} from "./test-suite.serialization";
+
+function testCase(id = "case-1"): RegexTestCase {
+ return {
+ id,
+ name: "Unicode word",
+ enabled: true,
+ flavour: "ecmascript",
+ flavourVersion: "ECMAScript 2025",
+ pattern: "\\p{Letter}+",
+ flags: ["g", "u"],
+ options: {},
+ scanAll: true,
+ subject: "Bérénice",
+ expectation: { kind: "match-count", count: 1 },
+ };
+}
+
+describe("standalone regex test-suite JSON", () => {
+ it("round-trips all reproducibility fields when subjects are included", () => {
+ const suite = importRegexTestSuite(
+ serializeRegexTestSuite([testCase()], true),
+ );
+
+ expect(suite.schemaVersion).toBe(1);
+ expect(suite.tests).toEqual([testCase()]);
+ });
+
+ it("omits sensitive subjects by default without mutating the source", () => {
+ const source = testCase();
+ const suite = importRegexTestSuite(
+ serializeRegexTestSuite([source], false),
+ );
+
+ expect(suite.tests[0]?.subject).toBe("");
+ expect(source.subject).toBe("Bérénice");
+ });
+
+ it("rejects malformed, unsupported, oversized, and over-count suites", () => {
+ expect(() => importRegexTestSuite("{")).toThrow("not valid JSON");
+ expect(() =>
+ importRegexTestSuite('{"schemaVersion":2,"tests":[]}'),
+ ).toThrow("schema version 1");
+ expect(() =>
+ importRegexTestSuite(
+ JSON.stringify({
+ schemaVersion: 1,
+ tests: Array.from({ length: MAXIMUM_REGEX_TESTS + 1 }, (_, index) =>
+ testCase(`case-${index}`),
+ ),
+ }),
+ ),
+ ).toThrow(`more than ${MAXIMUM_REGEX_TESTS} tests`);
+ expect(() =>
+ importRegexTestSuite(" ".repeat(MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES + 1)),
+ ).toThrow("32 MiB");
+ expect(() =>
+ importRegexTestSuite(
+ JSON.stringify({
+ schemaVersion: 1,
+ tests: [testCase("duplicate"), testCase("duplicate")],
+ }),
+ ),
+ ).toThrow(/test id.*duplicated/iu);
+ });
+
+ it("rejects an oversized export before JSON materialization", () => {
+ const largeSubject = "x".repeat(16 * 1024 * 1024);
+ const largeSuite = [
+ { ...testCase("large-1"), subject: largeSubject },
+ { ...testCase("large-2"), subject: largeSubject },
+ ];
+ expect(() => assertRegexTestSuiteWithinLimit(largeSuite)).toThrow(
+ "32 MiB aggregate state limit",
+ );
+ expect(() => serializeRegexTestSuite(largeSuite, true)).toThrow(
+ "32 MiB aggregate export limit",
+ );
+ });
+});
diff --git a/src/regex/tests/test-suite.serialization.ts b/src/regex/tests/test-suite.serialization.ts
new file mode 100644
index 0000000..4072536
--- /dev/null
+++ b/src/regex/tests/test-suite.serialization.ts
@@ -0,0 +1,108 @@
+import { parseRegexTests } from "../../project/project.validation";
+import {
+ projectDocumentUtf8ByteLength,
+ serializedProjectDocumentUtf8ByteLength,
+} from "../../project/project-limits";
+import type { RegexTestCase } from "./test-case.types";
+
+export const MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES = 32 * 1024 * 1024;
+
+export interface RegexTestSuiteV1 {
+ readonly schemaVersion: 1;
+ readonly tests: readonly RegexTestCase[];
+}
+
+function assertSuiteDocumentWithinLimit(
+ suite: RegexTestSuiteV1,
+ maximumBytes: number,
+ operation: "state" | "export",
+): void {
+ try {
+ projectDocumentUtf8ByteLength(suite, maximumBytes);
+ } catch (error) {
+ if (error instanceof Error && error.message.includes("32 MiB")) {
+ throw new Error(
+ `Test suite exceeds the 32 MiB aggregate ${operation} limit.`,
+ { cause: error },
+ );
+ }
+ throw error;
+ }
+}
+
+export function assertRegexTestSuiteWithinLimit(
+ tests: readonly RegexTestCase[],
+): void {
+ assertSuiteDocumentWithinLimit(
+ { schemaVersion: 1, tests },
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
+ "state",
+ );
+}
+
+export function serializeRegexTestSuite(
+ tests: readonly RegexTestCase[],
+ includeSubjects: boolean,
+): string {
+ const suite: RegexTestSuiteV1 = {
+ schemaVersion: 1,
+ tests: includeSubjects
+ ? tests
+ : tests.map((test) => ({ ...test, subject: "" })),
+ };
+ assertSuiteDocumentWithinLimit(
+ suite,
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES - 1,
+ "export",
+ );
+ const serialized = `${JSON.stringify(suite, null, 2)}\n`;
+ try {
+ serializedProjectDocumentUtf8ByteLength(
+ serialized,
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
+ );
+ } catch (error) {
+ if (error instanceof Error && error.message.includes("32 MiB")) {
+ throw new Error("Test-suite document exceeds the 32 MiB export limit.", {
+ cause: error,
+ });
+ }
+ throw error;
+ }
+ return serialized;
+}
+
+export function importRegexTestSuite(serialized: string): RegexTestSuiteV1 {
+ try {
+ serializedProjectDocumentUtf8ByteLength(
+ serialized,
+ MAXIMUM_REGEX_TEST_SUITE_FILE_BYTES,
+ );
+ } catch (error) {
+ if (error instanceof Error && error.message.includes("32 MiB")) {
+ throw new Error("Test-suite document exceeds the 32 MiB import limit.", {
+ cause: error,
+ });
+ }
+ throw error;
+ }
+ let value: unknown;
+ try {
+ value = JSON.parse(serialized);
+ } catch (error) {
+ throw new Error("Test-suite document is not valid JSON.", { cause: error });
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
+ throw new Error("Test-suite document must be a JSON object.");
+ }
+ const input = value as Record;
+ if (input.schemaVersion !== 1) {
+ throw new Error(
+ "Only regex-tools test-suite schema version 1 is supported.",
+ );
+ }
+ return {
+ schemaVersion: 1,
+ tests: parseRegexTests(input.tests),
+ };
+}
diff --git a/src/styles.css b/src/styles.css
new file mode 100644
index 0000000..57a69d1
--- /dev/null
+++ b/src/styles.css
@@ -0,0 +1,1497 @@
+:root {
+ --regex-purple: #7051bf;
+ --regex-purple-soft: color-mix(
+ in srgb,
+ var(--regex-purple) 13%,
+ var(--toolbox-surface)
+ );
+ --regex-mint: #41bda8;
+ --regex-warning: #b66d08;
+ --regex-success: #237b57;
+ --capture-1: #7357c8;
+ --capture-2: #087986;
+ --capture-3: #b14d24;
+ --capture-4: #b13775;
+ --capture-5: #4e7c1f;
+ --capture-6: #2766b5;
+ --capture-7: #8a6411;
+ --capture-8: #72544d;
+}
+
+[data-toolbox-theme="dark"] {
+ --regex-success: #62d19e;
+ --capture-1: #b7a2ff;
+ --capture-2: #55d5e3;
+ --capture-3: #ff9a70;
+ --capture-4: #ff8fc6;
+ --capture-5: #a8d96f;
+ --capture-6: #82b7ff;
+ --capture-7: #e6bd5c;
+ --capture-8: #d5aaa0;
+}
+
+@media (prefers-color-scheme: dark) {
+ [data-toolbox-theme="system"] {
+ --regex-success: #62d19e;
+ --capture-1: #b7a2ff;
+ --capture-2: #55d5e3;
+ --capture-3: #ff9a70;
+ --capture-4: #ff8fc6;
+ --capture-5: #a8d96f;
+ --capture-6: #82b7ff;
+ --capture-7: #e6bd5c;
+ --capture-8: #d5aaa0;
+ }
+}
+
+* {
+ box-sizing: border-box;
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+button,
+select,
+input[type="checkbox"] {
+ cursor: pointer;
+}
+
+button:focus-visible,
+input:focus-visible,
+select:focus-visible,
+textarea:focus-visible,
+.cm-editor.cm-focused {
+ outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 62%, transparent);
+ outline-offset: 2px;
+}
+
+.regex-application {
+ position: relative;
+ width: min(100%, 90rem);
+ margin-inline: auto;
+ padding: 1rem clamp(0.75rem, 2.5vw, 2rem) 3rem;
+ color: var(--toolbox-text);
+}
+
+.workbench-loading {
+ width: min(100%, 90rem);
+ margin: 2rem auto;
+ padding: 2rem;
+ color: var(--toolbox-muted);
+ text-align: center;
+}
+
+.panel {
+ min-width: 0;
+ border: 1px solid var(--toolbox-border);
+ border-radius: var(--toolbox-radius);
+ background: var(--toolbox-surface);
+ box-shadow: 0 8px 28px rgb(20 30 60 / 5%);
+}
+
+.panel-heading {
+ display: flex;
+ min-height: 4rem;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.8rem 1rem;
+ border-bottom: 1px solid var(--toolbox-border);
+}
+
+.editor-heading-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.35rem 0.75rem;
+ color: var(--toolbox-muted);
+ font-size: 0.78rem;
+}
+
+.panel-heading.compact {
+ min-height: 3rem;
+}
+
+.panel-heading h2,
+.panel-heading p {
+ margin: 0;
+}
+
+.panel-heading h2 {
+ font-size: 1rem;
+ line-height: 1.25;
+}
+
+.eyebrow {
+ color: var(--toolbox-muted);
+ font-size: 0.69rem;
+ font-weight: 750;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.provenance-badge,
+.status,
+.token-chips span {
+ display: inline-flex;
+ align-items: center;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 99rem;
+ padding: 0.25rem 0.5rem;
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ font-weight: 700;
+ line-height: 1.2;
+}
+
+.provenance-badge.is-engine {
+ border-color: color-mix(
+ in srgb,
+ var(--regex-mint) 55%,
+ var(--toolbox-border)
+ );
+ background: color-mix(in srgb, var(--regex-mint) 10%, var(--toolbox-surface));
+ color: color-mix(in srgb, var(--regex-mint) 70%, var(--toolbox-text));
+}
+
+.primary-button,
+.secondary-button,
+.icon-button {
+ border: 1px solid transparent;
+ border-radius: calc(var(--toolbox-radius) * 0.78);
+ padding: 0.55rem 0.75rem;
+ font-weight: 700;
+}
+
+.primary-button {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent);
+ color: var(--toolbox-accent-contrast);
+}
+
+.primary-button:hover:not(:disabled) {
+ background: var(--toolbox-accent-hover);
+}
+
+.secondary-button,
+.icon-button {
+ border-color: var(--toolbox-border);
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+}
+
+.secondary-button:hover:not(:disabled),
+.icon-button:hover:not(:disabled) {
+ border-color: color-mix(
+ in srgb,
+ var(--toolbox-accent) 55%,
+ var(--toolbox-border)
+ );
+ background: var(--toolbox-accent-soft);
+}
+
+button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.icon-button {
+ display: inline-grid;
+ width: 2rem;
+ height: 2rem;
+ place-items: center;
+ padding: 0;
+ font-size: 1.15rem;
+}
+
+.command-bar {
+ display: flex;
+ position: sticky;
+ z-index: 8;
+ top: 0.5rem;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 0.65rem;
+ margin-bottom: 0.75rem;
+ padding: 0.75rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: var(--toolbox-radius);
+ background: color-mix(in srgb, var(--toolbox-surface) 94%, transparent);
+ box-shadow: var(--toolbox-shadow);
+ backdrop-filter: blur(16px);
+}
+
+.command-bar > label:not(.check-control),
+.inline-controls label,
+.table-tools label:not(.check-control) {
+ display: grid;
+ gap: 0.25rem;
+}
+
+.command-bar label > span,
+.inline-controls label > span,
+.table-tools label > span,
+.stacked-control > span {
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+select,
+input[type="text"],
+input[type="search"],
+textarea,
+.stacked-control input {
+ min-height: 2.25rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.75);
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+}
+
+select,
+input[type="text"],
+input[type="search"],
+.stacked-control input {
+ padding: 0.35rem 0.55rem;
+}
+
+textarea {
+ width: 100%;
+ resize: vertical;
+ padding: 0.65rem;
+ font-family:
+ ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
+}
+
+.flag-selector {
+ display: flex;
+ min-inline-size: 0;
+ max-width: 100%;
+ align-items: center;
+ gap: 0.18rem;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.flag-selector legend {
+ margin-bottom: 0.25rem;
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.flag-selector label {
+ display: block;
+ position: relative;
+}
+
+.flag-selector input {
+ position: absolute;
+ opacity: 0;
+}
+
+.flag-selector span {
+ display: grid;
+ width: 2.25rem;
+ height: 2.25rem;
+ place-items: center;
+ border: 1px solid var(--toolbox-border);
+ background: var(--toolbox-surface);
+ color: var(--toolbox-muted);
+ font-family: ui-monospace, monospace;
+ font-weight: 800;
+}
+
+.flag-selector label:first-of-type span {
+ border-radius: 0.45rem 0 0 0.45rem;
+}
+
+.flag-selector label:last-of-type span {
+ border-radius: 0 0.45rem 0.45rem 0;
+}
+
+.flag-selector input:checked + span {
+ position: relative;
+ z-index: 1;
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent-soft);
+ color: var(--toolbox-accent);
+}
+
+.flag-selector input:focus-visible + span,
+.test-flag-selector input:focus-visible + span {
+ position: relative;
+ z-index: 2;
+ outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 62%, transparent);
+ outline-offset: 2px;
+}
+
+.test-flag-selector {
+ display: flex;
+ min-inline-size: 0;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.18rem;
+ margin: 0.75rem;
+ padding: 0;
+ border: 0;
+}
+
+.test-flag-selector legend {
+ width: 100%;
+ margin-bottom: 0.25rem;
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+ font-weight: 700;
+}
+
+.test-flag-selector label {
+ display: block;
+ position: relative;
+}
+
+.test-flag-selector input {
+ position: absolute;
+ opacity: 0;
+}
+
+.test-flag-selector span {
+ display: grid;
+ width: 2.25rem;
+ height: 2.25rem;
+ place-items: center;
+ border: 1px solid var(--toolbox-border);
+ border-radius: 0.45rem;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-muted);
+ font-family: ui-monospace, monospace;
+ font-weight: 800;
+}
+
+.test-flag-selector input:checked + span {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent-soft);
+ color: var(--toolbox-accent);
+}
+
+.check-control {
+ display: inline-flex;
+ min-height: 2.25rem;
+ align-items: center;
+ gap: 0.45rem;
+ color: var(--toolbox-text);
+ font-size: 0.82rem;
+ font-weight: 650;
+}
+
+.check-control input {
+ width: 1.05rem;
+ height: 1.05rem;
+ accent-color: var(--toolbox-accent);
+}
+
+.run-button {
+ min-width: 5rem;
+}
+
+.limit-banner,
+.scan-banner,
+.privacy-warning {
+ margin: 0.5rem 0;
+ padding: 0.65rem 0.8rem;
+ border: 1px solid
+ color-mix(in srgb, var(--regex-warning) 42%, var(--toolbox-border));
+ border-radius: var(--toolbox-radius);
+ background: color-mix(
+ in srgb,
+ var(--regex-warning) 9%,
+ var(--toolbox-surface)
+ );
+ color: color-mix(in srgb, var(--regex-warning) 75%, var(--toolbox-text));
+ font-size: 0.82rem;
+}
+
+.render-limit-banner {
+ margin: 0.65rem;
+ padding: 0.65rem 0.8rem;
+ border: 1px solid
+ color-mix(in srgb, var(--regex-warning) 48%, var(--toolbox-border));
+ border-radius: calc(var(--toolbox-radius) * 0.78);
+ background: color-mix(
+ in srgb,
+ var(--regex-warning) 9%,
+ var(--toolbox-surface)
+ );
+ color: var(--toolbox-text);
+ font-size: 0.8rem;
+ line-height: 1.45;
+}
+
+.workspace-toolbar {
+ display: flex;
+ position: relative;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ margin: 0.85rem 0 0.6rem;
+}
+
+.mode-tabs {
+ display: flex;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: auto;
+ box-sizing: border-box;
+ padding: 0.25rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: var(--toolbox-radius);
+ background: var(--toolbox-surface-soft);
+}
+
+.mode-tabs button {
+ border: 0;
+ border-radius: calc(var(--toolbox-radius) * 0.7);
+ padding: 0.55rem 0.8rem;
+ background: transparent;
+ color: var(--toolbox-muted);
+ font-weight: 700;
+ white-space: nowrap;
+}
+
+.mode-tabs button[aria-current="page"] {
+ background: var(--toolbox-surface);
+ box-shadow: 0 2px 8px rgb(20 30 60 / 8%);
+ color: var(--toolbox-accent);
+}
+
+.workspace-actions,
+.project-actions,
+.inline-controls,
+.table-tools {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: end;
+ gap: 0.55rem;
+}
+
+.project-popover {
+ position: absolute;
+ z-index: 12;
+ top: calc(100% + 0.4rem);
+ right: 0;
+ width: min(30rem, 100%);
+ padding-bottom: 1rem;
+ box-shadow: var(--toolbox-shadow);
+}
+
+.project-popover > p,
+.project-popover > label,
+.project-popover > .project-actions {
+ margin-inline: 1rem;
+}
+
+.project-status {
+ min-height: 1.4em;
+ margin: 0.75rem 1rem 0;
+ color: var(--toolbox-muted);
+ font-size: 0.8rem;
+}
+
+.run-status {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ align-items: center;
+ gap: 0.65rem;
+ min-height: 2.7rem;
+ margin-bottom: 0.75rem;
+ padding: 0.5rem 0.75rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: var(--toolbox-radius);
+ background: var(--toolbox-surface);
+ font-size: 0.8rem;
+}
+
+.run-status > span {
+ border-radius: 99rem;
+ padding: 0.2rem 0.45rem;
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.run-status > small {
+ color: var(--toolbox-muted);
+}
+
+.run-status.status-ready > span {
+ background: color-mix(
+ in srgb,
+ var(--regex-success) 12%,
+ var(--toolbox-surface)
+ );
+ color: var(--regex-success);
+}
+
+.run-status.status-timeout > span,
+.run-status.status-error > span {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-danger) 11%,
+ var(--toolbox-surface)
+ );
+ color: var(--toolbox-danger);
+}
+
+.primary-workspace,
+.result-workspace,
+.mode-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1.25fr) minmax(22rem, 0.75fr);
+ gap: 0.75rem;
+ margin-bottom: 0.75rem;
+}
+
+.mode-grid {
+ grid-template-columns: minmax(0, 0.9fr) minmax(22rem, 1.1fr);
+}
+
+.mode-span {
+ grid-column: 1 / -1;
+}
+
+.code-editor {
+ min-height: 8rem;
+ overflow: hidden;
+ background: color-mix(
+ in srgb,
+ var(--toolbox-surface) 96%,
+ var(--regex-purple)
+ );
+}
+
+.code-editor-host {
+ min-height: inherit;
+}
+
+.editor-limit-message {
+ margin: 0;
+ padding: 0.55rem 0.75rem;
+ border-top: 1px solid var(--toolbox-danger);
+ background: color-mix(
+ in srgb,
+ var(--toolbox-danger) 8%,
+ var(--toolbox-surface)
+ );
+ color: var(--toolbox-danger);
+ font-size: 0.78rem;
+}
+
+.pattern-editor {
+ min-height: 11rem;
+}
+
+.subject-editor {
+ min-height: 22rem;
+}
+
+.replacement-editor {
+ min-height: 9rem;
+}
+
+.output-editor {
+ min-height: 14rem;
+}
+
+.code-editor .cm-editor {
+ height: 100%;
+ min-height: inherit;
+ color: var(--toolbox-text);
+ font-family:
+ ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
+ font-size: 0.88rem;
+}
+
+.code-editor .cm-scroller {
+ min-height: inherit;
+ overflow: auto;
+}
+
+.code-editor .cm-content {
+ min-height: inherit;
+ padding: 0.8rem 0;
+ caret-color: var(--toolbox-accent);
+}
+
+.code-editor .cm-line {
+ padding-inline: 0.75rem;
+}
+
+.code-editor .cm-gutters {
+ border-right: 1px solid var(--toolbox-border);
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+}
+
+.code-editor .cm-activeLine,
+.code-editor .cm-activeLineGutter {
+ background: var(--toolbox-accent-soft);
+}
+
+.code-editor .cm-selectionBackground,
+.code-editor .cm-content ::selection {
+ background: color-mix(
+ in srgb,
+ var(--toolbox-focus) 38%,
+ transparent
+ ) !important;
+}
+
+.code-editor .cm-content[data-placeholder]:empty::before {
+ content: attr(data-placeholder);
+ color: var(--toolbox-muted);
+ pointer-events: none;
+}
+
+.cm-syntax-token {
+ border-radius: 0.15rem;
+}
+
+.cm-token-quantifier,
+.cm-token-anchor,
+.cm-token-word-boundary {
+ color: color-mix(in srgb, var(--regex-purple) 80%, var(--toolbox-text));
+ font-weight: 800;
+}
+
+.cm-token-character-class,
+.cm-token-unicode-property {
+ color: color-mix(in srgb, var(--regex-mint) 78%, var(--toolbox-text));
+}
+
+.cm-token-capture-group,
+.cm-token-named-capture-group,
+.cm-token-noncapture-group,
+.cm-token-lookahead,
+.cm-token-lookbehind,
+.cm-token-negative-lookahead,
+.cm-token-negative-lookbehind {
+ border-bottom: 2px solid currentColor;
+}
+
+.cm-diagnostic-error {
+ text-decoration: underline wavy var(--toolbox-danger) 1.5px;
+ text-underline-offset: 0.22rem;
+}
+
+.cm-diagnostic-warning {
+ text-decoration: underline wavy var(--regex-warning) 1.5px;
+}
+
+.cm-selected-range {
+ outline: 2px solid var(--toolbox-focus);
+ outline-offset: 1px;
+ background: color-mix(in srgb, var(--toolbox-focus) 16%, transparent);
+}
+
+.cm-subject-match {
+ border-radius: 0.18rem;
+ background: color-mix(in srgb, var(--regex-purple) 15%, transparent);
+ box-shadow: inset 0 -2px var(--regex-purple);
+}
+
+.cm-subject-capture {
+ border-radius: 0.15rem;
+ box-shadow: inset 0 -2px currentColor;
+}
+
+.cm-zero-width {
+ position: relative;
+ z-index: 2;
+ color: var(--toolbox-danger);
+ font-weight: 900;
+}
+
+.cm-capture-1 {
+ color: var(--capture-1);
+}
+.cm-capture-2 {
+ color: var(--capture-2);
+}
+.cm-capture-3 {
+ color: var(--capture-3);
+}
+.cm-capture-4 {
+ color: var(--capture-4);
+}
+.cm-capture-5 {
+ color: var(--capture-5);
+}
+.cm-capture-6 {
+ color: var(--capture-6);
+}
+.cm-capture-7 {
+ color: var(--capture-7);
+}
+.cm-capture-8 {
+ color: var(--capture-8);
+}
+
+.tree-panel {
+ min-height: 0;
+}
+
+.tree {
+ max-height: 30rem;
+ overflow: auto;
+ margin: 0;
+ padding: 0.45rem;
+ list-style: none;
+}
+
+.tree ul {
+ margin: 0 0 0 1rem;
+ padding: 0 0 0 0.55rem;
+ border-left: 1px solid var(--toolbox-border);
+ list-style: none;
+}
+
+.tree-row {
+ display: grid;
+ width: 100%;
+ grid-template-columns: minmax(5rem, auto) 1fr auto;
+ align-items: start;
+ gap: 0.6rem;
+ border: 1px solid transparent;
+ border-radius: calc(var(--toolbox-radius) * 0.65);
+ padding: 0.48rem 0.55rem;
+ background: transparent;
+ color: var(--toolbox-text);
+ text-align: left;
+}
+
+.tree-row:hover,
+.tree-row.is-selected {
+ border-color: var(--toolbox-border);
+ background: var(--toolbox-accent-soft);
+}
+
+.tree-row.is-selected {
+ box-shadow: inset 3px 0 var(--toolbox-focus);
+}
+
+.tree-token {
+ overflow: hidden;
+ color: var(--toolbox-accent);
+ font-family: ui-monospace, monospace;
+ font-size: 0.78rem;
+ font-weight: 800;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.tree-explanation {
+ font-size: 0.79rem;
+ line-height: 1.35;
+}
+
+.tree-meta {
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ white-space: nowrap;
+}
+
+.tree-details {
+ display: flex;
+ grid-column: 1 / -1;
+ flex-wrap: wrap;
+ gap: 0.25rem 0.65rem;
+ color: var(--toolbox-muted);
+ font-size: 0.65rem;
+ line-height: 1.35;
+}
+
+.tree-details > span {
+ overflow-wrap: anywhere;
+}
+
+.extraction-row {
+ grid-template-columns: minmax(8rem, auto) 1fr auto;
+}
+
+.tree-note {
+ margin: 0.1rem 0.5rem 0.4rem;
+ color: var(--toolbox-muted);
+ font-size: 0.7rem;
+}
+
+.status-did-not-participate {
+ opacity: 0.72;
+}
+
+.status-matched-empty {
+ color: var(--regex-warning);
+}
+
+.empty-state,
+.success-state {
+ margin: 0;
+ padding: 1.2rem;
+ color: var(--toolbox-muted);
+ text-align: center;
+}
+
+.success-state {
+ color: var(--regex-success);
+}
+
+.capture-panel,
+.diagnostics-panel,
+.reference-panel,
+.capability-panel {
+ margin-bottom: 0.75rem;
+}
+
+.table-tools {
+ padding: 0.65rem 0.75rem;
+ border-bottom: 1px solid var(--toolbox-border);
+}
+
+.capture-table-scroll {
+ max-height: 24rem;
+ overflow: auto;
+}
+
+table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.76rem;
+}
+
+th,
+td {
+ padding: 0.5rem 0.6rem;
+ border-bottom: 1px solid var(--toolbox-border);
+ text-align: left;
+ vertical-align: top;
+}
+
+th {
+ position: sticky;
+ z-index: 2;
+ top: 0;
+ background: var(--toolbox-surface-soft);
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+}
+
+tbody tr {
+ cursor: pointer;
+}
+
+tbody tr:hover,
+tbody tr.is-selected {
+ background: var(--toolbox-accent-soft);
+}
+
+.value-cell {
+ max-width: 26rem;
+ overflow: hidden;
+ font-family: ui-monospace, monospace;
+ text-overflow: ellipsis;
+ white-space: pre;
+}
+
+.code-point-cell {
+ max-width: 30rem;
+ color: var(--toolbox-muted);
+ font-family: ui-monospace, monospace;
+ font-size: 0.75rem;
+ overflow-wrap: anywhere;
+}
+
+.row-actions {
+ display: inline-flex;
+ gap: 0.3rem;
+}
+
+.pagination {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 1rem;
+ padding: 0.65rem;
+ color: var(--toolbox-muted);
+ font-size: 0.75rem;
+}
+
+.diagnostic-list,
+.token-list,
+.reference-list,
+.test-list,
+.list-preview {
+ margin: 0;
+ padding: 0.5rem;
+ list-style: none;
+}
+
+.diagnostic-list li + li,
+.token-list li + li,
+.test-list li + li {
+ margin-top: 0.35rem;
+}
+
+.diagnostic,
+.token-row {
+ display: grid;
+ width: 100%;
+ grid-template-columns: auto 1fr auto;
+ gap: 0.6rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.72);
+ padding: 0.6rem;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+ text-align: left;
+}
+
+.diagnostic > span {
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ font-weight: 800;
+ text-transform: uppercase;
+}
+
+.diagnostic > strong {
+ font-size: 0.8rem;
+}
+
+.diagnostic > small {
+ color: var(--toolbox-muted);
+}
+
+.token-row:hover,
+.token-row.is-selected {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent-soft);
+}
+
+.diagnostic-error {
+ border-left: 4px solid var(--toolbox-danger);
+}
+
+.diagnostic-warning {
+ border-left: 4px solid var(--regex-warning);
+}
+
+.token-row code {
+ color: var(--toolbox-accent);
+ font-weight: 800;
+}
+
+.token-row small {
+ color: var(--toolbox-muted);
+}
+
+.replacement-preview-list,
+.replacement-contribution-list {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.replacement-preview-list {
+ display: grid;
+ max-height: 34rem;
+ gap: 0.65rem;
+ overflow: auto;
+ padding: 0.7rem;
+}
+
+.replacement-match-preview {
+ overflow: hidden;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.78);
+ background: var(--toolbox-surface);
+}
+
+.replacement-match-preview > header {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 0.75rem;
+ padding: 0.6rem 0.7rem;
+ border-bottom: 1px solid var(--toolbox-border);
+ background: var(--toolbox-surface-soft);
+}
+
+.replacement-match-preview > header small {
+ color: var(--toolbox-muted);
+}
+
+.replacement-comparison {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.6rem;
+ padding: 0.7rem;
+}
+
+.replacement-comparison > div {
+ display: grid;
+ min-width: 0;
+ gap: 0.3rem;
+}
+
+.replacement-comparison span {
+ color: var(--toolbox-muted);
+ font-size: 0.7rem;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+
+.replacement-comparison code,
+.replacement-contribution-list span {
+ overflow-wrap: anywhere;
+ white-space: pre-wrap;
+}
+
+.replacement-contribution-list {
+ display: grid;
+ gap: 0.35rem;
+ padding: 0 0.7rem 0.7rem;
+}
+
+.replacement-contribution-list button {
+ display: grid;
+ width: 100%;
+ grid-template-columns: minmax(4rem, auto) minmax(6rem, 1fr) minmax(12rem, 2fr);
+ gap: 0.55rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.65);
+ padding: 0.5rem;
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+ text-align: left;
+}
+
+.replacement-contribution-list button:hover,
+.replacement-contribution-list button.is-selected {
+ border-color: var(--toolbox-accent);
+ background: var(--toolbox-accent-soft);
+}
+
+.replacement-contribution-list code {
+ color: var(--toolbox-accent);
+ font-weight: 800;
+}
+
+.replacement-contribution-list small {
+ color: var(--toolbox-muted);
+}
+
+.stacked-control {
+ display: grid;
+ gap: 0.3rem;
+ margin: 0.75rem;
+}
+
+.field-help,
+.capability-roadmap {
+ margin: 0.75rem;
+ color: var(--toolbox-muted);
+ font-size: 0.78rem;
+}
+
+.token-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.3rem;
+ margin: 0.75rem;
+}
+
+.list-preview {
+ max-height: 18rem;
+ overflow: auto;
+ counter-reset: rows;
+}
+
+.list-preview li {
+ display: grid;
+ grid-template-columns: 2rem 1fr;
+ gap: 0.5rem;
+ padding: 0.42rem;
+ border-bottom: 1px solid var(--toolbox-border);
+ counter-increment: rows;
+}
+
+.list-preview li::before {
+ content: counter(rows);
+ color: var(--toolbox-muted);
+ font-size: 0.68rem;
+ text-align: right;
+}
+
+.export-panel {
+ padding-bottom: 0.75rem;
+}
+
+.inline-controls {
+ padding: 0.75rem;
+}
+
+.test-create-panel {
+ padding-bottom: 0.8rem;
+}
+
+.test-form-actions,
+.test-run-actions,
+.test-suite-toolbar,
+.test-row-actions {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.45rem;
+}
+
+.test-form-actions,
+.test-suite-toolbar {
+ padding: 0.75rem;
+}
+
+.test-suite-toolbar {
+ border-bottom: 1px solid var(--toolbox-border);
+}
+
+.tests-panel > .privacy-warning,
+.tests-panel > [role="status"] {
+ margin-inline: 0.75rem;
+}
+
+.test-list li {
+ display: grid;
+ grid-template-columns:
+ auto minmax(12rem, 0.8fr) minmax(12rem, 1.2fr)
+ auto;
+ align-items: center;
+ gap: 0.7rem;
+ padding: 0.65rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.75);
+}
+
+.test-toggle {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+}
+
+.test-selection {
+ display: grid;
+ place-items: center;
+}
+
+.test-toggle > span {
+ display: grid;
+}
+
+.test-toggle small {
+ color: var(--toolbox-muted);
+}
+
+.test-result {
+ color: var(--toolbox-muted);
+ font-size: 0.77rem;
+}
+
+.test-result.is-pass {
+ color: var(--regex-success);
+}
+
+.test-result.is-fail {
+ color: var(--toolbox-danger);
+}
+
+.reference-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
+ gap: 0.55rem;
+}
+
+.reference-list li {
+ display: grid;
+ gap: 0.45rem;
+ align-content: start;
+ padding: 0.7rem;
+ border: 1px solid var(--toolbox-border);
+ border-radius: calc(var(--toolbox-radius) * 0.7);
+}
+
+.reference-list li > div {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.reference-list p,
+.reference-list small {
+ margin: 0;
+ color: var(--toolbox-muted);
+ font-size: 0.78rem;
+}
+
+.reference-meta {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.4rem;
+ margin: 0;
+}
+
+.reference-meta div {
+ min-width: 0;
+}
+
+.reference-meta dt {
+ color: var(--toolbox-muted);
+ font-size: 0.65rem;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+
+.reference-meta dd {
+ margin: 0.15rem 0 0;
+ overflow-wrap: anywhere;
+ font-size: 0.75rem;
+}
+
+.reference-list button {
+ justify-self: start;
+}
+
+.capability-grid,
+.help-limits {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
+ margin: 0;
+ padding: 0.65rem;
+}
+
+.capability-grid div,
+.help-limits div {
+ padding: 0.65rem;
+ border-bottom: 1px solid var(--toolbox-border);
+}
+
+.capability-grid dt,
+.help-limits dt {
+ color: var(--toolbox-muted);
+ font-size: 0.7rem;
+ font-weight: 750;
+ text-transform: uppercase;
+}
+
+.capability-grid dd,
+.help-limits dd {
+ margin: 0.2rem 0 0;
+ font-size: 0.84rem;
+}
+
+.app-dialog {
+ width: min(44rem, calc(100% - 2rem));
+ max-height: calc(100vh - 2rem);
+ overflow: hidden;
+ padding: 0;
+ border: 1px solid var(--toolbox-border);
+ border-radius: var(--toolbox-radius);
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+ box-shadow: var(--toolbox-shadow);
+}
+
+.app-dialog::backdrop {
+ background: rgb(8 12 24 / 64%);
+ backdrop-filter: blur(4px);
+}
+
+.app-dialog > header,
+.app-dialog > footer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 1rem;
+ padding: 0.8rem 1rem;
+ border-bottom: 1px solid var(--toolbox-border);
+}
+
+.app-dialog > footer {
+ justify-content: flex-end;
+ border-top: 1px solid var(--toolbox-border);
+ border-bottom: 0;
+}
+
+.app-dialog h2,
+.app-dialog p {
+ margin: 0;
+}
+
+.dialog-content {
+ display: grid;
+ max-height: 65vh;
+ gap: 1rem;
+ overflow: auto;
+ padding: 1rem;
+}
+
+.dialog-content section {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.dialog-content h3 {
+ margin: 0;
+ font-size: 1rem;
+}
+
+.dialog-content p {
+ color: var(--toolbox-muted);
+ line-height: 1.55;
+}
+
+.fatal-error {
+ width: min(100% - 2rem, 50rem);
+ margin: 3rem auto;
+ padding: 2rem;
+ border: 1px solid var(--toolbox-danger);
+ border-radius: var(--toolbox-radius);
+ background: var(--toolbox-surface);
+ color: var(--toolbox-text);
+}
+
+.visually-hidden {
+ position: absolute !important;
+ width: 1px !important;
+ height: 1px !important;
+ overflow: hidden !important;
+ clip: rect(0 0 0 0) !important;
+ clip-path: inset(50%) !important;
+ white-space: nowrap !important;
+}
+
+@media (max-width: 64rem) {
+ .command-bar {
+ position: static;
+ }
+
+ .primary-workspace,
+ .result-workspace,
+ .mode-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .mode-span {
+ grid-column: auto;
+ }
+
+ .workspace-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .workspace-actions {
+ justify-content: flex-end;
+ }
+
+ .project-popover {
+ top: 100%;
+ }
+
+ .replacement-comparison,
+ .replacement-contribution-list button {
+ grid-template-columns: 1fr;
+ }
+
+ .tree {
+ max-height: 24rem;
+ }
+}
+
+@media (max-width: 40rem) {
+ .regex-application {
+ padding-inline: 0.5rem;
+ }
+
+ .command-bar > label:not(.check-control) {
+ flex: 1 1 10rem;
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ .command-bar > label:not(.check-control) > select {
+ width: 100%;
+ min-width: 0;
+ }
+
+ .flag-selector {
+ flex-basis: 100%;
+ width: 100%;
+ min-width: 0;
+ max-width: 100%;
+ overflow-x: auto;
+ }
+
+ .workspace-actions {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .run-status {
+ grid-template-columns: auto 1fr;
+ }
+
+ .run-status small {
+ grid-column: 1 / -1;
+ }
+
+ .tree-row,
+ .extraction-row {
+ grid-template-columns: 1fr auto;
+ }
+
+ .tree-explanation {
+ grid-column: 1 / -1;
+ }
+
+ .test-list li {
+ grid-template-columns: auto 1fr;
+ }
+
+ .test-result {
+ grid-column: 2 / -1;
+ }
+
+ .test-row-actions {
+ grid-column: 2 / -1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}
diff --git a/src/test/setup.ts b/src/test/setup.ts
new file mode 100644
index 0000000..bb32bff
--- /dev/null
+++ b/src/test/setup.ts
@@ -0,0 +1,5 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup } from "@testing-library/react";
+import { afterEach } from "vitest";
+
+afterEach(cleanup);
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
new file mode 100644
index 0000000..2322940
--- /dev/null
+++ b/src/toolbox/manifest.source.json
@@ -0,0 +1,40 @@
+{
+ "$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
+ "schemaVersion": 1,
+ "id": "de.add-ideas.regex-tools",
+ "name": "Regex Tools",
+ "version": "0.1.0",
+ "description": "Develop, explain, test and apply regular expressions locally in the browser.",
+ "entry": "./",
+ "icon": "./favicon.svg",
+ "categories": ["developer", "text", "regex"],
+ "tags": ["regular-expression", "match", "replace", "explain", "test"],
+ "integration": {
+ "contextVersion": 1,
+ "launchModes": ["navigate", "new-tab"],
+ "embedding": "unsupported"
+ },
+ "requirements": {
+ "secureContext": false,
+ "workers": true,
+ "indexedDb": true,
+ "crossOriginIsolated": false,
+ "topLevelContext": false
+ },
+ "privacy": {
+ "processing": "local",
+ "fileUploads": false,
+ "telemetry": false
+ },
+ "source": {
+ "repository": "https://git.add-ideas.de/zemion/regex-tools",
+ "license": "GPL-3.0-or-later"
+ },
+ "actions": [
+ {
+ "id": "source",
+ "label": "Source",
+ "url": "https://git.add-ideas.de/zemion/regex-tools"
+ }
+ ]
+}
diff --git a/src/toolbox/manifest.ts b/src/toolbox/manifest.ts
new file mode 100644
index 0000000..71b28a3
--- /dev/null
+++ b/src/toolbox/manifest.ts
@@ -0,0 +1,4 @@
+import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
+import manifestSource from "./manifest.source.json";
+
+export const manifest = defineToolboxApp(parseToolboxApp(manifestSource));
diff --git a/src/types/scripts.d.ts b/src/types/scripts.d.ts
new file mode 100644
index 0000000..5eab7be
--- /dev/null
+++ b/src/types/scripts.d.ts
@@ -0,0 +1,13 @@
+declare module "../../scripts/package-release.mjs" {
+ export function safeArchivePath(fileName: string): string;
+ export function createDeterministicZip(
+ entries: readonly {
+ readonly name: string;
+ readonly data: Uint8Array;
+ }[],
+ ): Uint8Array;
+}
+
+declare module "../../scripts/checksum-release.mjs" {
+ export function sha256(data: Uint8Array): string;
+}
diff --git a/src/version.ts b/src/version.ts
new file mode 100644
index 0000000..fa089d9
--- /dev/null
+++ b/src/version.ts
@@ -0,0 +1,3 @@
+export const APPLICATION_VERSION = "0.1.0";
+export const SYNTAX_PROFILE = "community";
+export const REGEXPP_VERSION = "4.12.2";
diff --git a/src/workers/ecmascript.worker.ts b/src/workers/ecmascript.worker.ts
new file mode 100644
index 0000000..755bc46
--- /dev/null
+++ b/src/workers/ecmascript.worker.ts
@@ -0,0 +1,51 @@
+///
+import {
+ executeEcmaScript,
+ replaceEcmaScript,
+} from "../regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter";
+import {
+ WORKER_PROTOCOL_VERSION,
+ workerError,
+ type EngineWorkerOperation,
+ type EngineWorkerResult,
+ type WorkerRequest,
+ type WorkerResponse,
+} from "../regex/execution/worker-protocol";
+
+const scope = self as DedicatedWorkerGlobalScope;
+
+scope.onmessage = (
+ event: MessageEvent>,
+) => {
+ const request = event.data;
+ if (request.protocolVersion !== WORKER_PROTOCOL_VERSION) return;
+ let response: WorkerResponse;
+ try {
+ const payload: EngineWorkerResult =
+ request.payload.kind === "execute"
+ ? {
+ kind: "execute",
+ result: executeEcmaScript(request.payload.request),
+ }
+ : {
+ kind: "replace",
+ result: replaceEcmaScript(request.payload.request),
+ };
+ response = {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: request.requestId,
+ generation: request.generation,
+ ok: true,
+ payload,
+ };
+ } catch (error) {
+ response = {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: request.requestId,
+ generation: request.generation,
+ ok: false,
+ error: workerError(error),
+ };
+ }
+ scope.postMessage(response);
+};
diff --git a/src/workers/syntax.worker.ts b/src/workers/syntax.worker.ts
new file mode 100644
index 0000000..3dcca57
--- /dev/null
+++ b/src/workers/syntax.worker.ts
@@ -0,0 +1,49 @@
+///
+import { EcmaScriptSyntaxProvider } from "../regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
+import {
+ WORKER_PROTOCOL_VERSION,
+ workerError,
+ type SyntaxWorkerOperation,
+ type SyntaxWorkerResult,
+ type WorkerRequest,
+ type WorkerResponse,
+} from "../regex/execution/worker-protocol";
+
+const provider = new EcmaScriptSyntaxProvider();
+const scope = self as DedicatedWorkerGlobalScope;
+
+scope.onmessage = async (
+ event: MessageEvent>,
+) => {
+ const request = event.data;
+ if (request.protocolVersion !== WORKER_PROTOCOL_VERSION) return;
+ let response: WorkerResponse;
+ try {
+ const payload: SyntaxWorkerResult =
+ request.payload.kind === "parse-pattern"
+ ? {
+ kind: "parse-pattern",
+ result: await provider.parsePattern(request.payload.request),
+ }
+ : {
+ kind: "parse-replacement",
+ result: await provider.parseReplacement(request.payload.request),
+ };
+ response = {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: request.requestId,
+ generation: request.generation,
+ ok: true,
+ payload,
+ };
+ } catch (error) {
+ response = {
+ protocolVersion: WORKER_PROTOCOL_VERSION,
+ requestId: request.requestId,
+ generation: request.generation,
+ ok: false,
+ error: workerError(error),
+ };
+ }
+ scope.postMessage(response);
+};
diff --git a/tests/browser/workbench.spec.ts b/tests/browser/workbench.spec.ts
new file mode 100644
index 0000000..8e77353
--- /dev/null
+++ b/tests/browser/workbench.spec.ts
@@ -0,0 +1,781 @@
+import { expect, test, type Page } from "@playwright/test";
+
+async function setEditor(page: Page, testId: string, value: string) {
+ const content = page.getByTestId(testId).locator(".cm-content");
+ await content.fill(value);
+}
+
+async function waitForReady(page: Page) {
+ await expect(page.locator(".run-status")).toHaveClass(/status-ready/u, {
+ timeout: 15_000,
+ });
+}
+
+test("standalone ECMAScript workbench parses, explains and extracts", async ({
+ page,
+}) => {
+ const pageErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ await page.goto("./");
+
+ await expect(
+ page.getByRole("heading", { name: "Regex Tools" }),
+ ).toBeVisible();
+ await waitForReady(page);
+ await expect(
+ page.getByRole("heading", { name: "Explanation tree" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Extraction tree" }),
+ ).toBeVisible();
+ await expect(page.locator(".extraction-row")).toHaveCount(8);
+ await expect(page.getByText("alice", { exact: true }).first()).toBeVisible();
+ await expect(
+ page.getByText("Bérénice", { exact: true }).first(),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("columnheader", { name: "Native start" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("checkbox", { name: "Global (g)" }),
+ ).toBeChecked();
+ await expect(page.locator(".cm-capture-1").first()).toHaveAttribute(
+ "title",
+ /group 1 · date/u,
+ );
+ await expect(
+ page
+ .locator('[aria-live="polite"]')
+ .filter({ hasText: "Completed locally" }),
+ ).toHaveCount(1);
+ expect(pageErrors).toEqual([]);
+});
+
+test("editing reports malformed syntax and recovers with exact matches", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ const patternEditor = page
+ .getByTestId("editor-regular-expression-pattern")
+ .locator(".cm-content");
+ const originalPattern = await patternEditor.textContent();
+ await patternEditor.click();
+ await patternEditor.press("End");
+ await patternEditor.press("x");
+ await expect(patternEditor).toHaveText(`${originalPattern}x`);
+ await setEditor(page, "editor-regular-expression-pattern", "(");
+ await expect(
+ page
+ .locator(".diagnostic-error")
+ .filter({ hasText: /unterminated|invalid/iu }),
+ ).toBeVisible();
+ await expect(page.locator(".run-status")).toContainText(
+ "Execution is paused",
+ );
+
+ await setEditor(page, "editor-regular-expression-pattern", "(?\\w+)");
+ await setEditor(page, "editor-test-text", "hello world");
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+ await expect(page.locator(".extraction-row").first()).toContainText("hello");
+});
+
+test("rapid pattern and replacement edits cannot run with stale syntax", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await page.getByRole("button", { name: "Replace", exact: true }).click();
+
+ await setEditor(page, "editor-regular-expression-pattern", "(?\\w+)");
+ await setEditor(page, "editor-replacement-template", "$");
+ await setEditor(
+ page,
+ "editor-regular-expression-pattern",
+ "(?[A-Z])",
+ );
+ await setEditor(page, "editor-replacement-template", "[$]");
+ await setEditor(page, "editor-test-text", "A B");
+
+ const runButton = page.getByRole("button", { name: "Run", exact: true });
+ await expect(runButton).toBeDisabled();
+ await runButton.evaluate((button: HTMLButtonElement) => button.click());
+ await expect(page.locator(".run-status")).not.toHaveClass(/status-running/u);
+ await expect(page.locator(".extraction-row")).toHaveCount(0);
+
+ await expect(runButton).toBeEnabled();
+ const currentNamedToken = page.locator(".token-row").filter({
+ has: page.locator("code", { hasText: "$" }),
+ });
+ await expect(currentNamedToken.locator("code")).toHaveText("$");
+ await expect(currentNamedToken.locator("small")).toHaveText("named capture");
+
+ await page.getByRole("checkbox", { name: "Ignore case (i)" }).check();
+ await expect(runButton).toBeDisabled();
+ await runButton.evaluate((button: HTMLButtonElement) => button.click());
+ await expect(page.locator(".run-status")).not.toHaveClass(/status-running/u);
+
+ await expect(runButton).toBeEnabled();
+ await runButton.click();
+ await waitForReady(page);
+ await expect(
+ page.getByTestId("editor-replacement-output").locator(".cm-content"),
+ ).toHaveText("[A] [B]");
+});
+
+test("manual editing clears results that no longer describe the inputs", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+
+ await setEditor(page, "editor-regular-expression-pattern", "nomatch");
+
+ await expect(page.locator(".run-status")).toHaveClass(/status-idle/u);
+ await expect(page.locator(".run-status")).toContainText(
+ "previous results were cleared",
+ );
+ await expect(page.locator(".run-status small")).toHaveCount(0);
+ await expect(page.locator(".extraction-row")).toHaveCount(0);
+ await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
+ await expect(page.locator(".cm-subject-match")).toHaveCount(0);
+});
+
+test("capture-group resource limits block execution visibly", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+
+ await setEditor(
+ page,
+ "editor-regular-expression-pattern",
+ "()".repeat(1_001),
+ );
+
+ await expect(page.locator(".run-status")).toHaveClass(/status-error/u);
+ await expect(page.locator(".run-status")).toContainText(
+ "above the 1,000 group execution limit",
+ );
+ await expect(page.locator(".run-status small")).toHaveCount(0);
+ await expect(page.locator(".extraction-row")).toHaveCount(0);
+});
+
+test("large valid patterns bound syntax tree nodes and editor decorations", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+
+ await setEditor(page, "editor-regular-expression-pattern", "a".repeat(2_100));
+
+ await expect(page.getByTestId("pattern-mark-render-limit")).toBeVisible();
+ await expect(page.getByTestId("explanation-render-limit")).toContainText(
+ "2,000",
+ );
+ await expect(
+ page
+ .getByRole("tree", { name: "Pattern explanation" })
+ .getByRole("treeitem"),
+ ).toHaveCount(2_000);
+});
+
+test("the pattern editor rejects input above its hard resource limit", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ const editor = page
+ .getByTestId("editor-regular-expression-pattern")
+ .locator(".cm-content");
+ const previousPattern = await editor.textContent();
+
+ await editor.fill("a".repeat(64 * 1_024 + 1));
+
+ await expect(page.getByRole("alert")).toContainText(
+ "limited to 65,536 UTF-16 units",
+ );
+ await expect(editor).toHaveText(previousPattern ?? "");
+});
+
+test("large match sets bound tree nodes and editor highlights explicitly", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await setEditor(page, "editor-regular-expression-pattern", "a");
+ await setEditor(page, "editor-test-text", "a".repeat(10_001));
+
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+
+ await expect(page.locator(".run-status")).toContainText("10,000 matches");
+ await expect(page.locator(".extraction-row")).toHaveCount(2_000);
+ await expect(page.getByTestId("extraction-render-limit")).toContainText(
+ "first 2,000 of 20,000",
+ );
+ await expect(page.getByTestId("subject-mark-render-limit")).toContainText(
+ "first 2,000 of 10,000",
+ );
+ await expect(
+ page.locator(".diagnostic").filter({ hasText: "configured limit" }),
+ ).toBeVisible();
+});
+
+test("list export stays disabled when the engine returned a limited prefix", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await page.getByRole("button", { name: "List", exact: true }).click();
+ await setEditor(page, "editor-regular-expression-pattern", "a");
+ await setEditor(page, "editor-test-text", "a".repeat(10_001));
+
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+
+ await expect(page.getByText(/engine limit reached/u)).toBeVisible();
+ await expect(
+ page.getByText(/engine stopped collecting matches/u),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("button", { name: "Download TEXT" }),
+ ).toBeDisabled();
+});
+
+test("subject caret selection identifies a capture without replacing it", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ const subjectEditor = page
+ .getByTestId("editor-test-text")
+ .locator(".cm-content");
+ const originalSubject = await subjectEditor.textContent();
+
+ await subjectEditor.click();
+ await subjectEditor.press("Control+Home");
+ await expect(
+ page
+ .getByRole("tree", { name: "Match extraction" })
+ .locator(".tree-row.is-selected"),
+ ).toContainText("Group 1 — date");
+ await expect(
+ page
+ .getByRole("grid", { name: "Captured matches" })
+ .locator("tbody tr")
+ .first(),
+ ).toHaveAttribute("aria-selected", "true");
+ expect(
+ await subjectEditor.evaluate((editor) => {
+ const selection = globalThis.getSelection();
+ return (
+ selection?.isCollapsed === true && editor.contains(selection.anchorNode)
+ );
+ }),
+ ).toBe(true);
+
+ await subjectEditor.press("x");
+ await expect(subjectEditor).toHaveText(`x${originalSubject}`);
+});
+
+test("test-text whitespace and caret position are visible on request", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await setEditor(page, "editor-test-text", "a b\ncd");
+
+ await expect(page.getByText("Ln 2, Col 3", { exact: true })).toBeVisible();
+ await page.getByRole("checkbox", { name: "Visible whitespace" }).check();
+ await expect(
+ page.getByTestId("editor-test-text").locator(".cm-highlightSpace"),
+ ).toHaveCount(1);
+});
+
+test("capture-row selection remains synchronized after editor highlighting", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ const captureRows = page
+ .getByRole("grid", { name: "Captured matches" })
+ .locator("tbody tr");
+
+ await captureRows.first().focus();
+ await captureRows.first().press("Enter");
+
+ await expect(captureRows.first()).toHaveAttribute("aria-selected", "true");
+ await expect(captureRows.first()).toHaveClass(/is-selected/u);
+ await expect(
+ page
+ .getByRole("tree", { name: "Match extraction" })
+ .locator(".tree-row.is-selected"),
+ ).toContainText("Group 1 — date");
+ await expect(
+ page.getByTestId("editor-test-text").locator(".cm-selected-range"),
+ ).toHaveCount(1);
+
+ await page
+ .getByRole("tree", { name: "Match extraction" })
+ .getByRole("treeitem", { name: /^Match 1 —/u })
+ .click();
+ await expect(
+ page
+ .getByRole("tree", { name: "Pattern explanation" })
+ .locator(".tree-row.is-selected"),
+ ).toContainText("Regular-expression pattern");
+});
+
+test("pattern explanation selection synchronizes capture result views", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+
+ await page
+ .getByRole("tree", { name: "Pattern explanation" })
+ .locator(".tree-row")
+ .filter({ hasText: "group 1 · date" })
+ .first()
+ .click();
+
+ await expect(
+ page
+ .getByRole("tree", { name: "Match extraction" })
+ .locator(".tree-row.is-selected"),
+ ).toContainText("Group 1 — date");
+ await expect(
+ page
+ .getByRole("grid", { name: "Captured matches" })
+ .locator("tbody tr")
+ .first(),
+ ).toHaveAttribute("aria-selected", "true");
+ await expect(
+ page.getByTestId("editor-test-text").locator(".cm-selected-range"),
+ ).toHaveCount(1);
+});
+
+test("replacement and list modes use native and typed templates", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByRole("button", { name: "Replace", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Replacement tokens" }),
+ ).toBeVisible();
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+ await expect(
+ page.getByTestId("editor-replacement-output").locator(".cm-content"),
+ ).toContainText("alice — 2026-07-24");
+ const perMatchPreview = page.locator(".replacement-preview-panel");
+ await expect(
+ perMatchPreview.getByRole("heading", { name: "Per-match preview" }),
+ ).toBeVisible();
+ await expect(
+ perMatchPreview.locator(".replacement-match-preview"),
+ ).toHaveCount(2);
+ await expect(
+ perMatchPreview.locator(".replacement-match-preview").first(),
+ ).toContainText("2026-07-24 alice");
+ await expect(
+ perMatchPreview.locator(".replacement-match-preview").first(),
+ ).toContainText("alice — 2026-07-24");
+
+ const userContribution = perMatchPreview
+ .getByRole("button", { name: /Insert named capture “user”/u })
+ .first();
+ await userContribution.click();
+ await expect(userContribution).toHaveAttribute("aria-pressed", "true");
+ await expect(
+ page
+ .getByRole("tree", { name: "Match extraction" })
+ .locator(".tree-row.is-selected"),
+ ).toContainText("Group 2 — user");
+ await expect(
+ page
+ .getByTestId("editor-replacement-template")
+ .locator(".cm-selected-range"),
+ ).toHaveCount(1);
+ await expect(
+ page
+ .getByTestId("editor-regular-expression-pattern")
+ .locator(".cm-selected-range"),
+ ).toHaveCount(1);
+ await expect(
+ page.getByTestId("editor-test-text").locator(".cm-selected-range"),
+ ).toHaveCount(1);
+ await expect(
+ page.getByTestId("editor-replacement-output").locator(".cm-selected-range"),
+ ).toHaveCount(1);
+
+ await page.getByRole("button", { name: "List", exact: true }).click();
+ await expect(
+ page.getByRole("heading", { name: "Generated rows" }),
+ ).toBeVisible();
+ await expect(page.locator(".list-preview li")).toHaveCount(2);
+ await expect(page.locator(".list-preview li").first()).toContainText(
+ "alice — 2026-07-24",
+ );
+});
+
+test("sticky scan-all replacement uses every authoritative match", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+
+ await page.getByRole("checkbox", { name: "Global (g)" }).press("Space");
+ await page.getByRole("checkbox", { name: "Unicode (u)" }).press("Space");
+ await page.getByRole("checkbox", { name: "Sticky (y)" }).press("Space");
+ await page.getByRole("checkbox", { name: "Scan all" }).check();
+ await setEditor(page, "editor-regular-expression-pattern", "a");
+ await setEditor(page, "editor-test-text", "aa");
+ await waitForReady(page);
+
+ await page.getByRole("button", { name: "Replace", exact: true }).click();
+ await setEditor(page, "editor-replacement-template", "x");
+ await waitForReady(page);
+
+ await expect(page.locator(".run-status")).toContainText("2 matches");
+ await expect(
+ page
+ .getByRole("tree", { name: "Match extraction" })
+ .getByRole("treeitem", { name: /^Match \d+ —/u }),
+ ).toHaveCount(2);
+ await expect(
+ page.getByTestId("editor-replacement-output").locator(".cm-content"),
+ ).toHaveText("xx");
+});
+
+test("unit tests run through the actual worker engine", async ({ page }) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByRole("button", { name: "Unit tests", exact: true }).click();
+ await page
+ .getByRole("textbox", { name: "Name", exact: true })
+ .fill("Current dates");
+ await page.getByRole("button", { name: "Add test" }).click();
+ await page.getByRole("button", { name: "Run all" }).click();
+ await expect(page.locator(".test-result")).toContainText("Pass", {
+ timeout: 15_000,
+ });
+});
+
+test("unit-test cases can be captured, cloned, edited, selectively run, filtered, and moved as JSON", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByRole("button", { name: "Unit tests", exact: true }).click();
+
+ await page.getByRole("button", { name: "Add current result" }).click();
+ await page
+ .getByRole("button", { name: "Clone Current match result" })
+ .click();
+ await page
+ .getByRole("button", { name: "Edit Current match result copy" })
+ .click();
+ await page
+ .getByRole("textbox", { name: "Name", exact: true })
+ .fill("Expected failure");
+ await page
+ .getByRole("combobox", { name: "Expectation" })
+ .selectOption("should-not-match");
+ await page.getByRole("button", { name: "Update test" }).click();
+
+ await page
+ .getByRole("checkbox", { name: "Select Current match result" })
+ .check();
+ await page.getByRole("button", { name: "Run selected" }).click();
+ await expect(
+ page
+ .locator(".test-list li")
+ .filter({ hasText: "Current match result" })
+ .first()
+ .locator(".test-result"),
+ ).toContainText("Pass", { timeout: 15_000 });
+ await expect(
+ page
+ .locator(".test-list li")
+ .filter({ hasText: "Expected failure" })
+ .locator(".test-result"),
+ ).toHaveText("Not run");
+
+ await page.getByRole("checkbox", { name: "Select Expected failure" }).check();
+ await page.getByRole("button", { name: "Run selected" }).click();
+ await expect(
+ page
+ .locator(".test-list li")
+ .filter({ hasText: "Expected failure" })
+ .locator(".test-result"),
+ ).toContainText("Fail", { timeout: 15_000 });
+
+ await page.getByRole("checkbox", { name: "Failures only" }).check();
+ await expect(page.locator(".test-list li")).toHaveCount(1);
+ await expect(page.locator(".test-list li")).toContainText("Expected failure");
+
+ await page
+ .getByRole("checkbox", { name: "Include subjects in JSON" })
+ .check();
+ const downloadPromise = page.waitForEvent("download");
+ await page.getByRole("button", { name: "Export JSON" }).click();
+ const download = await downloadPromise;
+ expect(download.suggestedFilename()).toBe("regex-tests.regex-tests.json");
+ const downloadedPath = await download.path();
+ if (!downloadedPath) throw new Error("Test-suite download path unavailable");
+ await page
+ .getByLabel("Import test-suite JSON file")
+ .setInputFiles(downloadedPath);
+ await expect(page.getByText("Imported 2 tests.")).toBeVisible();
+ await page.getByRole("checkbox", { name: "Failures only" }).uncheck();
+ await expect(page.locator(".test-list li")).toHaveCount(4);
+});
+
+test("a running unit-test suite can be cancelled without affecting interactive execution", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$");
+ await setEditor(page, "editor-test-text", `${"a".repeat(48)}!`);
+ await page.getByRole("button", { name: "Unit tests", exact: true }).click();
+ await page
+ .getByRole("combobox", { name: "Expectation" })
+ .selectOption("must-time-out");
+ await page
+ .getByRole("spinbutton", { name: "Per-test timeout (ms, optional)" })
+ .fill("10000");
+ await page.getByRole("button", { name: "Add test" }).click();
+ await page.getByRole("button", { name: "Run all" }).click();
+ await page.getByRole("button", { name: "Cancel" }).click();
+ await expect(page.getByRole("button", { name: "Run all" })).toBeEnabled();
+
+ await page.getByRole("button", { name: "Match", exact: true }).click();
+ await setEditor(page, "editor-regular-expression-pattern", "^ok$");
+ await setEditor(page, "editor-test-text", "ok");
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+ await expect(page.locator(".run-status")).toContainText("1 matches");
+});
+
+test("invalid Toolbox context degrades to the complete standalone app", async ({
+ page,
+}) => {
+ await page.goto(
+ "./?toolbox=https%3A%2F%2Fcross-origin.invalid%2Ftoolbox.catalog.json",
+ );
+ await expect(
+ page.getByRole("heading", { name: "Regex Tools" }),
+ ).toBeVisible();
+ await waitForReady(page);
+ await expect(
+ page.getByRole("button", { name: "Run", exact: true }),
+ ).toBeEnabled();
+});
+
+test("a valid same-origin Toolbox context connects the shared app switcher", async ({
+ page,
+}) => {
+ await page.goto("./?toolbox=/toolbox.catalog.json");
+ await waitForReady(page);
+
+ await expect(page.locator(".toolbox-shell")).toHaveAttribute(
+ "data-toolbox-context",
+ "connected",
+ );
+ await page.getByRole("button", { name: "Apps" }).click();
+ const switcher = page.getByRole("navigation", {
+ name: "Toolbox applications",
+ });
+ await expect(switcher).toBeVisible();
+ await expect(
+ switcher.getByRole("link", { name: "Regex Tools" }),
+ ).toHaveAttribute("aria-current", "page");
+});
+
+test("the same production build loads from a deep nested path", async ({
+ page,
+}) => {
+ const responses: string[] = [];
+ page.on("response", (response) => {
+ if (response.status() >= 400) responses.push(response.url());
+ });
+ await page.goto("/deep/nested/regex/");
+ await expect(
+ page.getByRole("heading", { name: "Regex Tools" }),
+ ).toBeVisible();
+ await waitForReady(page);
+ await expect(page.locator(".extraction-row")).toHaveCount(8);
+ expect(responses).toEqual([]);
+});
+
+test("responsive layout retains editors and result trees", async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 844 });
+ await page.goto("./");
+ await waitForReady(page);
+ await expect(
+ page.getByTestId("editor-regular-expression-pattern"),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Explanation tree" }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Extraction tree" }),
+ ).toBeVisible();
+ const overflows = await page.evaluate(
+ () => document.documentElement.scrollWidth > window.innerWidth,
+ );
+ expect(overflows).toBe(false);
+});
+
+test("dark theme capture and success text meets normal-text contrast", async ({
+ page,
+}) => {
+ await page.emulateMedia({ colorScheme: "dark" });
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await setEditor(
+ page,
+ "editor-regular-expression-pattern",
+ "(a)(b)(c)(d)(e)(f)(g)(h)",
+ );
+ await setEditor(page, "editor-test-text", "abcdefgh");
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+ await expect(page.locator(".cm-capture-8").first()).toBeAttached();
+
+ const ratios = await page.evaluate(() => {
+ const channels = (value: string): [number, number, number] => {
+ const values = value.match(/[\d.]+/gu)?.map(Number) ?? [];
+ if (value.startsWith("color(srgb")) {
+ return [values[0]! * 255, values[1]! * 255, values[2]! * 255];
+ }
+ return [values[0]!, values[1]!, values[2]!];
+ };
+ const luminance = (color: [number, number, number]) => {
+ const normalized = color.map((channel) => {
+ const value = channel / 255;
+ return value <= 0.04045
+ ? value / 12.92
+ : ((value + 0.055) / 1.055) ** 2.4;
+ });
+ return (
+ 0.2126 * normalized[0]! +
+ 0.7152 * normalized[1]! +
+ 0.0722 * normalized[2]!
+ );
+ };
+ const contrast = (foreground: string, background: string) => {
+ const left = luminance(channels(foreground));
+ const right = luminance(channels(background));
+ return (Math.max(left, right) + 0.05) / (Math.min(left, right) + 0.05);
+ };
+ const editor = document.querySelector(".code-editor");
+ if (!editor) throw new Error("Editor is unavailable.");
+ const editorBackground = getComputedStyle(editor).backgroundColor;
+ const captureRatios = Array.from({ length: 8 }, (_, index) => {
+ const capture = document.querySelector(
+ `.cm-capture-${index + 1}`,
+ );
+ if (!capture) throw new Error(`Capture ${index + 1} is unavailable.`);
+ return contrast(getComputedStyle(capture).color, editorBackground);
+ });
+ const success = document.querySelector(
+ ".run-status.status-ready > span",
+ );
+ if (!success) throw new Error("Success status is unavailable.");
+ return [
+ ...captureRatios,
+ contrast(
+ getComputedStyle(success).color,
+ getComputedStyle(success).backgroundColor,
+ ),
+ ];
+ });
+ expect(Math.min(...ratios)).toBeGreaterThanOrEqual(4.5);
+});
+
+test("project file picker is named and omitted from keyboard order", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByRole("button", { name: "Project", exact: true }).click();
+
+ const input = page.getByLabel("Import regex project JSON file");
+ await expect(input).toHaveAttribute("tabindex", "-1");
+ await expect(input).toHaveAttribute("type", "file");
+});
+
+test("result trees and capture rows support keyboard navigation", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+
+ const explanationItems = page
+ .getByRole("tree", { name: "Pattern explanation" })
+ .getByRole("treeitem");
+ await explanationItems.first().focus();
+ await explanationItems.first().press("ArrowDown");
+ await expect(explanationItems.nth(1)).toBeFocused();
+
+ const captureRows = page
+ .getByRole("grid", { name: "Captured matches" })
+ .locator("tbody tr");
+ await captureRows.first().focus();
+ await captureRows.first().press("ArrowDown");
+ await expect(captureRows.nth(1)).toBeFocused();
+
+ const globalFlag = page.getByRole("checkbox", { name: "Global (g)" });
+ await globalFlag.focus();
+ const flagFocus = await globalFlag.evaluate((input) => {
+ const indicator = input.nextElementSibling;
+ if (!(indicator instanceof HTMLElement)) return null;
+ const style = getComputedStyle(indicator);
+ return { style: style.outlineStyle, width: style.outlineWidth };
+ });
+ expect(flagFocus).toEqual({ style: "solid", width: "3px" });
+});
+
+test("a timeout kills the worker and the next request uses a fresh engine", async ({
+ page,
+}) => {
+ await page.goto("./");
+ await waitForReady(page);
+ await page.getByLabel("Live").uncheck();
+ await page.getByLabel("Manual limit").selectOption("250");
+ await setEditor(page, "editor-regular-expression-pattern", "^(a|aa)+$");
+ await setEditor(page, "editor-test-text", `${"a".repeat(48)}!`);
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await expect(page.locator(".run-status")).toHaveClass(/status-timeout/u, {
+ timeout: 15_000,
+ });
+ await expect(page.locator(".run-status")).toContainText(
+ "worker was terminated",
+ );
+ await expect(page.locator(".run-status small")).toHaveCount(0);
+ await expect(page.locator(".extraction-row")).toHaveCount(0);
+ await expect(page.locator(".capture-table-scroll tbody tr")).toHaveCount(0);
+ await expect(page.locator(".cm-subject-match")).toHaveCount(0);
+
+ await setEditor(page, "editor-regular-expression-pattern", "^ok$");
+ await setEditor(page, "editor-test-text", "ok");
+ await page.getByRole("button", { name: "Run", exact: true }).click();
+ await waitForReady(page);
+ await expect(page.locator(".run-status")).toContainText("1 matches");
+});
diff --git a/tests/conformance/ecmascript.conformance.test.ts b/tests/conformance/ecmascript.conformance.test.ts
new file mode 100644
index 0000000..600f9bc
--- /dev/null
+++ b/tests/conformance/ecmascript.conformance.test.ts
@@ -0,0 +1,248 @@
+import { describe, expect, it } from "vitest";
+import { EcmaScriptSyntaxProvider } from "../../src/regex/syntax/providers/ecmascript/EcmaScriptSyntaxProvider";
+import { executeEcmaScript } from "../../src/regex/execution/adapters/ecmascript/EcmaScriptEngineAdapter";
+
+const provider = new EcmaScriptSyntaxProvider();
+
+const cases = [
+ {
+ name: "named capture",
+ pattern: "(?\\w+)",
+ flags: ["u"],
+ subject: "alice",
+ value: "alice",
+ },
+ {
+ name: "lookbehind",
+ pattern: "(?<=€)\\d+",
+ flags: ["u"],
+ subject: "€42",
+ value: "42",
+ },
+ {
+ name: "backreference",
+ pattern: "(?\\w+)\\s+\\k",
+ flags: ["u"],
+ subject: "echo echo",
+ value: "echo echo",
+ },
+ {
+ name: "Unicode property",
+ pattern: "\\p{Letter}+",
+ flags: ["u"],
+ subject: "Grüße 42",
+ value: "Grüße",
+ },
+] as const;
+
+describe("project-authored ECMAScript conformance cases", () => {
+ for (const testCase of cases) {
+ it(testCase.name, async () => {
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: testCase.pattern,
+ flags: testCase.flags,
+ options: {},
+ });
+ expect(syntax.accepted).toBe(true);
+ const result = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern: testCase.pattern,
+ flags: testCase.flags,
+ subject: testCase.subject,
+ captureMetadata: syntax.captures,
+ scanAll: false,
+ maximumMatches: 100,
+ maximumCaptureRows: 1_000,
+ });
+ expect(result.accepted).toBe(true);
+ expect(result.matches[0]?.value).toBe(testCase.value);
+ });
+ }
+
+ it("keeps astral ranges in UTF-16 code units", async () => {
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(?😀)",
+ flags: ["u"],
+ options: {},
+ });
+ const result = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern: "(?😀)",
+ flags: ["u"],
+ subject: "x😀y",
+ captureMetadata: syntax.captures,
+ scanAll: false,
+ maximumMatches: 10,
+ maximumCaptureRows: 10,
+ });
+ expect(result.matches[0]?.range).toEqual({
+ startUtf16: 1,
+ endUtf16: 3,
+ });
+ expect(result.matches[0]?.captures[0]?.nativeRange).toEqual({
+ start: 1,
+ end: 3,
+ unit: "utf16",
+ });
+ });
+
+ it.each([
+ {
+ name: "global iteration",
+ pattern: "\\d",
+ flags: ["g"],
+ subject: "1 2",
+ values: ["1", "2"],
+ },
+ {
+ name: "sticky iteration",
+ pattern: "\\w",
+ flags: ["y"],
+ subject: "ab",
+ values: ["a"],
+ },
+ {
+ name: "ignore case",
+ pattern: "hello",
+ flags: ["i"],
+ subject: "HELLO",
+ values: ["HELLO"],
+ },
+ {
+ name: "multiline anchors",
+ pattern: "^second$",
+ flags: ["m"],
+ subject: "first\nsecond\nthird",
+ values: ["second"],
+ },
+ {
+ name: "dot all",
+ pattern: "a.b",
+ flags: ["s"],
+ subject: "a\nb",
+ values: ["a\nb"],
+ },
+ {
+ name: "Unicode sets",
+ pattern: "[\\p{Letter}&&\\p{ASCII}]+",
+ flags: ["v"],
+ subject: "abcä",
+ values: ["abc"],
+ },
+ ])("implements $name flag semantics", async (testCase) => {
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: testCase.pattern,
+ flags: testCase.flags,
+ options: {},
+ });
+ expect(syntax.accepted).toBe(true);
+ const result = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern: testCase.pattern,
+ flags: testCase.flags,
+ subject: testCase.subject,
+ captureMetadata: syntax.captures,
+ scanAll: false,
+ maximumMatches: 100,
+ maximumCaptureRows: 1_000,
+ });
+ expect(result.accepted).toBe(true);
+ expect(result.matches.map((match) => match.value)).toEqual(testCase.values);
+ });
+
+ it("preserves an explicit user indices flag", async () => {
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(a)",
+ flags: ["d"],
+ options: {},
+ });
+ const result = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern: "(a)",
+ flags: ["d"],
+ subject: "a",
+ captureMetadata: syntax.captures,
+ scanAll: false,
+ maximumMatches: 10,
+ maximumCaptureRows: 10,
+ });
+
+ expect(result.accepted).toBe(true);
+ expect(result.flags.userFlags).toBe("d");
+ expect(result.flags.internallyAddedIndicesFlag).toBe(false);
+ expect(result.matches[0]?.captures[0]?.range).toEqual({
+ startUtf16: 0,
+ endUtf16: 1,
+ });
+ });
+
+ it("reports malformed syntax from both provider and engine", async () => {
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern: "(",
+ flags: [],
+ options: {},
+ });
+ expect(syntax.accepted).toBe(false);
+ expect(syntax.diagnostics[0]?.range).toEqual({
+ startUtf16: 1,
+ endUtf16: 1,
+ });
+
+ const execution = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern: "(",
+ flags: [],
+ subject: "",
+ captureMetadata: [],
+ scanAll: false,
+ maximumMatches: 10,
+ maximumCaptureRows: 10,
+ });
+ expect(execution.accepted).toBe(false);
+ expect(execution.diagnostics[0]?.code).toBe("compile-error");
+ });
+
+ it("records the current runtime's duplicate-name behavior", async () => {
+ const pattern = "(?a)|(?b)";
+ const syntax = await provider.parsePattern({
+ flavour: "ecmascript",
+ pattern,
+ flags: [],
+ options: {},
+ });
+ const execution = executeEcmaScript({
+ flavour: "ecmascript",
+ pattern,
+ flags: [],
+ subject: "b",
+ captureMetadata: syntax.captures,
+ scanAll: false,
+ maximumMatches: 10,
+ maximumCaptureRows: 10,
+ });
+ let runtimeSupportsDuplicateNames = true;
+ try {
+ new RegExp(pattern);
+ } catch {
+ runtimeSupportsDuplicateNames = false;
+ }
+
+ expect(execution.accepted).toBe(runtimeSupportsDuplicateNames);
+ if (runtimeSupportsDuplicateNames) {
+ expect(
+ execution.matches[0]?.captures.find(
+ (capture) =>
+ capture.groupName === "value" &&
+ capture.status !== "did-not-participate",
+ )?.value,
+ ).toBe("b");
+ } else {
+ expect(execution.diagnostics[0]?.code).toBe("compile-error");
+ }
+ });
+});
diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md
new file mode 100644
index 0000000..c78a874
--- /dev/null
+++ b/tests/fixtures/README.md
@@ -0,0 +1,13 @@
+# Test fixture provenance
+
+The 0.1.0 test suite uses only small, project-authored patterns and subjects.
+They exercise public ECMAScript semantics without copying a third-party corpus.
+
+- RegexLib was not copied because its pages do not present a clear
+ redistribution licence.
+- RGXP.RU was not copied because a suitable fixture redistribution licence was
+ not established.
+- RegexHub was inspected as a possible future MIT-licensed adversarial-pattern
+ source, but no RegexHub source or fixture is distributed in this release.
+- Test262 and runtime conformance sets are future work and require a pinned
+ adapter plus per-file provenance before any material is included.
diff --git a/tsconfig.app.json b/tsconfig.app.json
new file mode 100644
index 0000000..d24455b
--- /dev/null
+++ b/tsconfig.app.json
@@ -0,0 +1,27 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023", "DOM", "DOM.Iterable", "WebWorker"],
+ "module": "ESNext",
+ "types": ["vite/client"],
+ "allowArbitraryExtensions": true,
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "erasableSyntaxOnly": true,
+ "noFallthroughCasesInSwitch": true,
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "useDefineForClassFields": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true
+ },
+ "include": ["src", "tests"]
+}
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/tsconfig.node.json b/tsconfig.node.json
new file mode 100644
index 0000000..df3e8ce
--- /dev/null
+++ b/tsconfig.node.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
+ "target": "ES2023",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "types": ["node"],
+ "skipLibCheck": true,
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "verbatimModuleSyntax": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true
+ },
+ "include": [
+ "vite.config.ts",
+ "playwright.config.ts",
+ "eslint.config.mjs",
+ "scripts/**/*.mjs"
+ ]
+}
diff --git a/vite.config.ts b/vite.config.ts
new file mode 100644
index 0000000..a61db3f
--- /dev/null
+++ b/vite.config.ts
@@ -0,0 +1,18 @@
+///
+import { defineConfig } from "vite";
+import react from "@vitejs/plugin-react";
+
+export default defineConfig({
+ base: "./",
+ plugins: [react()],
+ worker: {
+ format: "es",
+ },
+ test: {
+ environment: "jsdom",
+ setupFiles: "./src/test/setup.ts",
+ css: true,
+ restoreMocks: true,
+ exclude: ["tests/browser/**", "node_modules/**", "dist/**"],
+ },
+});